JsonUI
← GuidesBuilding a custom componentAuthoring a custom component in JsonUI is a three-layer contract — spec, project whitelist, platform converter — plus a scaffold and a hand-editable component body. This guide walks the full chain using the docs site's own CodeBlock component as the running example, so every file path is one you can open.~20 min read
1. Why spec-firstJsonUI used to allow a 'standard pattern custom' shortcut where you could author a converter directly without a spec. That mode was removed in April 2026 because it caused three-platform type drift, made the docs site unable to auto-document components, and let half-finished components leak into production. Today every custom component starts with a spec at docs/components/json/<name>.component.json — validated before anything else runs, then projected into each platform by the scaffold.
2. The component specThe spec lists every prop (name, type, default, description), every exposed event, and — if it can contain children — the allowed slots. `jsonui-doc init component Foo` scaffolds a skeleton; `jsonui-doc validate component <file>` confirms the shape. One file, three readers: MCP agents consume it to plan work, the docs generator renders it to the reference page, the scaffolder emits the converter from it. Keep it honest — there is no second source of truth.
docs/components/json/badge.component.json
{
"type": "component_spec",
"version": "1.0",
"metadata": {
"name": "Badge",
"displayName": "Badge",
"category": "Display",
"platforms": ["web", "ios", "android"]
},
"props": {
"items": [
{ "name": "text", "type": "String", "description": "Visible text." },
{ "name": "color", "type": "String", "defaultValue": "neutral",
"description": "Semantic color key (neutral / success / warn / danger)." }
]
},
"slots": { "items": [] },
"structure": { "components": [], "layout": {} }
}
shell
# Scaffold a skeleton
jsonui-doc init component Badge
 
# Validate the shape
jsonui-doc validate component docs/components/json/badge.component.json
3. Identity: metadata.name, and nothing elseA custom component's identity is the `metadata.name` inside its own spec. It is required, and nothing is substituted when it is missing. A screen that declares the component writes the same string in its customComponents entry — the component spec is the authority and the screen has to agree with it. This is less a new rule than a line the tools already drew: every renderer that shows a name falls back to a placeholder when one is absent, and the single place that matches on identity refuses to fall back and reports the gap instead. Both errors surface when you run `jsonui-test generate unit-stubs`.• Required, with no default. An unnamed component is not given one, because every unnamed component would then answer to the same identity and merge silently into a single owner.• The screen's customComponents entry carries name and specFile. Its name must equal the component spec's metadata.name; when the two spellings drift, ownership is computed from the wrong one.• Display may substitute; matching may not. A heading that reads 'Component' because a name was missing is harmless. An identity resolved to a substituted value is not — it points ownership at something that was never declared.• A green run is not proof that you comply. The check names which ownership sources it consulted and marks that set complete or partial. Sources complete is not targets covered: a target that no source resolves has zero owners, which is neither exactly one nor two or more, so neither direction reports it. Read the unowned count rather than the absence of red.
identity must agree on both sides
// docs/components/json/badge.component.json — the authority
{ "metadata": { "name": "Badge" } }
 
// docs/screens/json/<screen>.spec.json — must agree with it
{ "structure": { "customComponents": [
{ "name": "Badge",
"specFile": "badge.component.json",
"description": "Small status pill." }
] } }
 
// a disagreement is reported, not resolved:
// PROBLEM <screen>: declares component 'Badgee' but
// badge.component.json names itself 'Badge'.
4. Register with the projectOnce the spec validates, add the component's type name to .jsonui-doc-rules.json → rules.componentTypes.screen. This whitelist is enforced by the Layout JSON validator; an unregistered type fails with 'Invalid component type: X'. It is also the source of truth the docs site uses to auto-enumerate 'available custom components' on the reference page, which is how CodeBlock, TableOfContents, and Search show up there.
.jsonui-doc-rules.json (excerpt)
{
"rules": {
"componentTypes": {
"screen": [
"CodeBlock",
"TableOfContents",
"Search",
"Badge"
]
}
}
}
5. Register with each platformEach platform keeps its own converter registry. The scaffold appends one line for you, but knowing the map makes debugging easier: Web → rjui_tools/lib/react/converters/extensions/converter_mappings.rb, iOS → sjui_tools/lib/swiftui/views/extensions/converter_mappings.rb, Android → kjui_tools/lib/compose/components/extensions/component_mappings.rb. The key in each Hash is the spec's type name (must match exactly); the value is the converter class name. If the Hash is missing your entry, the type reaches the layout validator and fails there — not at build time.
converter_mappings.rb
# jsonui-doc-web/rjui_tools/lib/react/converters/extensions/converter_mappings.rb
module SjuiTools
module React
module Converters
module Extensions
CONVERTER_MAPPINGS = {
'CodeBlock' => 'CodeBlockConverter',
'TableOfContents' => 'TableOfContentsConverter',
'Search' => 'SearchConverter',
'Badge' => 'BadgeConverter', # scaffold appends this line
}.freeze
end
end
end
end
6. What the scaffold writes`jui generate converter --from docs/components/json/<name>.component.json` writes nine files total — three per platform. Re-running the scaffold is safe: the converter file is protected from overwrite once it exists; only attribute_definitions and the mappings entry are touched. Pass `--skip-existing` to make the whole run a no-op when the converter already exists (useful in CI: the command exits 0 instead of erroring on conflict). Each platform gets three artifacts, all living under that platform's extensions/ directory:
shell
jui generate converter --from docs/components/json/badge.component.json
• <name>_converter.rb — the build-time translator (Layout JSON → JSX / Compose / SwiftUI). Hand-edit this for custom binding, class-name construction, child-node handling.• attribute_definitions/<Name>.json — declares which props the converter recognises. Regenerated from the spec on every run. Treat as read-only.• converter_mappings.rb — the per-platform Hash that makes the type name resolvable. Scaffold appends one line; existing entries are never touched.
7. Hand-edit boundariesWhich files survive a re-scaffold, which get overwritten, and which you never touch. This is the map most first-time authors wish they had printed on their wall:• You own: <name>.component.json, .jsonui-doc-rules.json, <name>_converter.rb, the Swift / Kotlin / TSX component impl under src/components/.• Scaffold writes once, you maintain: converter_mappings.rb (scaffold appends; reordering or removing is OK).• Overwritten every run: attribute_definitions/<Name>.json. Change the spec, not this file.• Never touch (@generated banner): Layout JSON, ViewModelBase, Hook, Data interface, StringManager. All regenerated on every jui build.
8. .jsonui-type-map.json ≠ component whitelistTwo different files, two different jobs, one common confusion. .jsonui-type-map.json maps custom data types (TypeScript interfaces / Swift structs / Kotlin data classes) across platforms — things like NextReadLink or ReferenceAttributeRow. .jsonui-doc-rules.json registers custom UI component types — things like CodeBlock or TableOfContents. A 'Unregistered custom types' warning from jui verify points at the type-map; an 'Invalid component type' error points at the doc-rules. Learn to read the error string and you will reach for the right file first time.
the two files side-by-side
// .jsonui-type-map.json — cross-platform custom DATA types
{
"version": "1.0",
"types": {
"NextReadLink": { "class": "NextReadLink" },
"ReferenceAttributeRow": { "class": "ReferenceAttributeRow" }
}
}
 
// .jsonui-doc-rules.json — custom UI COMPONENT whitelist
{
"rules": {
"componentTypes": {
"screen": ["CodeBlock", "TableOfContents", "Search", "Badge"]
}
}
}
Keep reading
Writing layoutsThe layout idioms that let a custom component land gracefully./guides/writing-layouts
Writing your first specThe spec-first muscle you will reuse here./guides/writing-your-first-spec