Custom components
Extend Joy DOM with renderer-specific UI.
Joy DOM renders a focused subset of HTML. When you need something outside that subset - a chart, a payment button, a platform-native control - describe it in JSON as a custom node and let the renderer fill it in.
This chapter adds a contact-button to the business card.
Anatomy
A custom node looks just like a built-in node, except the type is a kebab-case custom name:
{
"type": "contact-button",
"props": {
"id": "primary"
},
"children": ["Get in touch"]
}Joy DOM enforces three rules on the name, drawn from the HTML custom element spec:
- Must contain at least one hyphen (
-). - Must be all lowercase.
- Must not collide with a reserved name (e.g.
font-face,annotation-xml). See §3.6.
Register a React component
The React renderer takes a components map keyed by custom name. Each value is a React component that receives the resolved props:
import { , type } from "@joy-dom/react";
import type { Spec } from "@joy-dom/spec";
function ({ , , }: ) {
return (
<
={}
={}
={() => ..("mailto:hello@example.com")}
>
{}
</>
);
}
export function ({ }: { : Spec }) {
return < ={} ={{ "contact-button": }} />;
}The component receives:
| Prop | What's in it |
|---|---|
children | Rendered child nodes and text from the JSON. |
node | The raw Joy DOM node (type, props, children). |
id | The resolved node id (if any). |
className | The document's class list for the node, joined. |
style | Built-in nodes only; undefined for custom types. |
Style it like a built-in
Custom components participate in the full style cascade. Style them with a class selector in the document:
{
"style": {
".cta": {
"display": "flex",
"padding": {
"value": 12,
"unit": "px"
},
"backgroundColor": "#0f172a",
"color": "#ffffff",
"borderRadius": {
"value": 8,
"unit": "px"
},
"borderWidth": {
"value": 0,
"unit": "px"
}
}
}
}Then reference it from the node:
{
"type": "contact-button",
"props": {
"id": "primary",
"className": ["cta"]
},
"children": ["Get in touch"]
}Missing registrations throw
If a document references a custom node type without a registered component, the React renderer throws at render time. This is intentional - silent fallback would hide a misconfiguration that almost always points at a real bug.
Cross-renderer parity
Each renderer registers components in its own idiomatic way (props in React, view builders in
SwiftUI, composable functions in Compose). Use the same type name across renderers so the
document stays portable.
Add it to the card
The full business card with the button:
{
"version": 1,
"style": {
".card": {
"display": "flex",
"flexDirection": "column",
"gap": {
"value": 8,
"unit": "px"
},
"padding": {
"value": 24,
"unit": "px"
}
},
"h1": {
"display": "flex",
"fontSize": {
"value": 28,
"unit": "px"
},
"fontWeight": "bold"
},
"p": {
"display": "flex",
"color": "#475569"
},
".cta": {
"display": "flex",
"padding": {
"value": 12,
"unit": "px"
},
"backgroundColor": "#0f172a",
"color": "#ffffff",
"borderRadius": {
"value": 8,
"unit": "px"
},
"borderWidth": {
"value": 0,
"unit": "px"
}
}
},
"breakpoints": [],
"layout": {
"type": "div",
"props": {
"className": ["card"]
},
"children": [
{
"type": "h1",
"children": ["Avery Chen"]
},
{
"type": "p",
"children": ["Product designer · Tokyo"]
},
{
"type": "contact-button",
"props": {
"id": "primary",
"className": ["cta"]
},
"children": ["Get in touch"]
}
]
}
}Wrap-up
You now have a complete, responsive, extensible Joy DOM document. The last chapter ships it: where to put the JSON, how to load it at runtime, and what print-mode looks like. See chapter 5.
Spec references: §3.6 Custom nodes · §7 Custom components