Interactive widgets
A feedback card built from custom components that read and write host state through React context.
A Joy DOM document describes a static tree, but the renderer is still your own React app. Register custom components for the parts that need behavior and let React hold the state. The document stays serializable; the interactivity lives in the host.
This use case builds a feedback card with a text input, a star rating, and a submit button — each a custom component. Type in the field and the React state below the card updates as you go.
Example
Share your feedback
Your name
Rating
submit-button reads its handler from the same context.The document declares two custom nodes and one action binding. Everything else is plain Joy DOM:
{ "version": 1, "style": { ".card": { "display": "flex", "flexDirection": "column", "fontFamily": "\"Geist\", ui-sans-serif, system-ui, sans-serif", "gap": { "value": 20, "unit": "px" }, "padding": { "value": 28, "unit": "px" } }, "h2": { "display": "flex", "fontSize": { "value": 20, "unit": "px" }, "fontWeight": "bold" }, ".field": { "display": "flex", "flexDirection": "column", "gap": { "value": 6, "unit": "px" } }, ".label": { "display": "flex", "fontSize": { "value": 13, "unit": "px" }, "color": "#64748b" }, ".input": { "display": "flex", "borderColor": "#cbd5e1", "borderStyle": "solid", "borderWidth": { "value": 1, "unit": "px" }, "borderRadius": { "value": 8, "unit": "px" }, "padding": { "value": 10, "unit": "px" }, "fontSize": { "value": 15, "unit": "px" } }, ".stars": { "display": "flex", "gap": { "value": 4, "unit": "px" }, "fontSize": { "value": 24, "unit": "px" } }, ".submit": { "display": "flex", "justifyContent": "center", "backgroundColor": "#0f172a", "color": "#f8fafc", "padding": { "value": 12, "unit": "px" }, "borderStyle": "none", "borderRadius": { "value": 8, "unit": "px" }, "fontFamily": "\"Geist\", ui-sans-serif, system-ui, sans-serif", "fontSize": { "value": 16, "unit": "px" }, "fontWeight": "bold" } }, "breakpoints": [ { "conditions": [ { "type": "feature", "name": "width", "operator": "<", "value": 480, "unit": "px" } ], "nodes": {}, "style": { ".card": { "display": "flex", "padding": { "value": 20, "unit": "px" }, "gap": { "value": 16, "unit": "px" } } } } ], "layout": { "type": "div", "props": { "className": [ "card" ] }, "children": [ { "type": "h2", "children": [ "Share your feedback" ] }, { "type": "div", "props": { "className": [ "field" ] }, "children": [ { "type": "p", "props": { "className": [ "label" ] }, "children": [ "Your name" ] }, { "type": "text-field", "props": { "id": "name", "className": [ "input" ] } } ] }, { "type": "div", "props": { "className": [ "field" ] }, "children": [ { "type": "p", "props": { "className": [ "label" ] }, "children": [ "Rating" ] }, { "type": "star-rating", "props": { "className": [ "stars" ] } } ] }, { "type": "submit-button", "props": { "className": [ "submit" ] }, "children": [ "Send feedback" ] } ] }}How state flows
Custom nodes don't receive auto-wired event handlers, so each interactive part — the text field, the star rating, and the submit button — reads and writes host state through context. (Built-in nodes can instead carry a declarative event binding; see §8 Events and actions.) Module-level components keep a stable identity across re-renders, which is what lets the controlled input hold focus while you type:
const FeedbackContext = createContext<FeedbackStore | null>(null);
function TextField({ id, className }: JoyDomComponentProps) {
const { name, setName } = useContext(FeedbackContext)!;
return (
<input id={id} className={className} value={name} onChange={(e) => setName(e.target.value)} />
);
}A div with an onclick is not focusable or announced as a button, so the submit control is a custom node too — it renders a real <button> and pulls its handler from the same context:
function SubmitButton({ className, children }: JoyDomComponentProps) {
const { submit } = useContext(FeedbackContext)!;
return (
<button type="button" className={className} onClick={submit}>
{children}
</button>
);
}The host owns the state, registers the components, and renders a live summary outside the Joy DOM tree:
function FeedbackDemo() {
const [name, setName] = useState("");
const [rating, setRating] = useState(0);
const submit = () => save({ name, rating });
return (
<FeedbackContext.Provider value={{ name, rating, setName, setRating, submit }}>
<JoyDom
spec={feedbackSpec}
components={{
"text-field": TextField,
"star-rating": StarRating,
"submit-button": SubmitButton,
}}
/>
</FeedbackContext.Provider>
);
}A custom node never reaches back into the JSON to mutate it. State lives in React, the document stays declarative, and the same JSON renders on every platform.
Styling
Custom nodes style themselves from the document cascade. Give the node a className and write the rule in style, exactly as you would for a built-in:
{
"type": "text-field",
"props": {
"id": "name",
"className": ["input"]
}
}The renderer passes className to your component; the .input rule in the document's style applies through the shadow root. See §7.1 Component props.
Spec references: §7 Custom components · §8 Events and actions