Spec-driven UI rendering for hyper-element. Turns flat JSON specs (produced by LLM tool calls) into live DOM trees using hyper-element's efficient tagged template rendering engine.
When an LLM calls the json_render tool, it produces a JSON spec in
the format { root: string, elements: { [key]: ElementDef } }. This
module renders those specs into real DOM components with:
- 12 built-in component types: Card, Button, Text, Alert, Progress, Row, Column, Divider, CodeBlock, Image, Checklist, TextField
- Extensible registry: register custom component types via
registerComponent() - Efficient re-rendering: uses
Html.wire()for keyed DOM reuse - Streaming support: handles partial specs gracefully with loading placeholders
- Event dispatch: interactive components emit
jr-actionCustomEvents
<!-- Import the CSS -->
<link rel="stylesheet" href="hyper-element/src/json-render/json-render.css" />
<!-- Import registers <json-render> automatically -->
<script type="module">
import 'hyper-element/json-render';
</script>
<!-- Use it: place the JSON spec as body content -->
<json-render>
{"root":"msg","elements":{"msg":{"type":"Text","props":{"content":"Hello from
json-render!"}}}}
</json-render>The spec lives between the tags as ordinary text — no attribute, no quote
escaping. JSON structure characters ({ } [ ] : , ") are all
HTML-safe, so the spec survives HTML parsing untouched. The only characters
to watch for are literal < or > inside string values: encode them as
\u003c / \u003e JSON escapes to stay HTML-safe. & is always safe
because element.textContent resolves & back to &.
Updating the spec after mount is a plain text-content assignment:
document.querySelector('json-render').textContent = JSON.stringify(nextSpec);hyper-element's MutationObserver sees the childList change and re-renders
automatically.
The <json-render> element exposes three conveniences for consumers
that programmatically manage specs (e.g. an LLM chat client replacing
the spec after each tool-use round-trip):
const jr = document.querySelector('json-render');
// replaceSpec(spec) — stringify + assign + trigger re-render in one call.
// Accepts either an object (stringified internally) or a pre-serialized
// JSON string (assigned directly, no double-encoding).
jr.replaceSpec({
root: 'msg',
elements: { msg: { type: 'Text', props: { content: 'Hi' } } },
});
// toolUseId — first-class property that mirrors data-tool-use-id.
// Useful for correlating a <json-render> with a specific LLM tool_use
// block when multiple interactive UI fragments exist in one response.
jr.toolUseId = 'tu_abc123'; // stamps data-tool-use-id="tu_abc123"
jr.toolUseId; // 'tu_abc123'
jr.toolUseId = null; // removes the attribute
// onaction — React-style IDL property for the jr-action event.
// Assigning a function registers a single listener; reassigning
// atomically replaces it (never stacks); null removes it; anything
// else throws TypeError so typos surface loudly instead of silently
// no-oping. The same setter backs the declarative attribute form
// inside hyper-element templates (see "Declarative onaction" below).
jr.onaction = (e) => console.log(e.detail.action, e.detail.params);
jr.onaction = null; // removes the listener| Behaviour | Contract |
|---|---|
jr.onaction = fn |
Registers fn as a bubble-phase jr-action listener. Getter round-trips to fn. |
jr.onaction = fn2 |
Removes fn and registers fn2 atomically — never stacks. |
jr.onaction = null |
Removes any active listener. Getter returns null. |
jr.onaction = undefined |
Normalized to null (explicit unbind), never throws. |
jr.onaction = 'oops' (post-connect) |
Throws TypeError synchronously from the setter. Prior handler left intact. |
jr.onaction = 'oops' (pre-connect, then appendChild) |
Caught during setup()'s re-apply, logged via console.error. Element renders normally. jr.onaction reads as null. |
jr.onaction = fn; jr.onaction = fn; |
Idempotent — no rebind, no duplicate dispatch. |
Object.getOwnPropertyDescriptor(jr, 'onaction') |
{ get, set, enumerable: false, configurable: true } — matches the shape of native on* accessors. |
Object.keys(jr) |
Does not include 'onaction' (non-enumerable by design). |
| Disconnect | Teardown removes any active listener and deletes the property descriptor. Reconnection starts with onaction === undefined (then re-installs the accessor, starting from null). |
| Dispatch ordering | The capture-phase data-jr-busy listener runs first (DOM guarantee). By the time the bubble-phase onaction handler runs, data-jr-busy="true" and data-jr-busy-action="<name>" are already stamped on the host. |
Why pre-connect typos log instead of throwing: a throw inside
connectedCallback is not propagated out of appendChild (the
custom-elements spec requires the UA to report it as an unhandled
error and continue the insertion). Throwing out of setup() would
also abort the rest of the setup hook, skipping toolUseId and the
teardown return and leaving the element with a broken render
pipeline. Catching at the narrow re-apply site preserves loud
surfacing via console.error while keeping the element functional.
Post-connect assignments have no such constraint — the setter throws
synchronously because the failure site is the typo site.
import {
renderSpec,
registerComponent,
validateSpec,
} from 'hyper-element/json-render';
// Render a spec programmatically inside a hyper-element component
hyperElement('my-view', (Html, ctx) => {
const spec = JSON.parse(ctx.wrappedContent);
return renderSpec(Html, spec, ctx.element);
});
// Register a custom component type
registerComponent(
'MyChart',
(Html, def, key, kids, hostEl) =>
Html.wire(def, ':' + key)`<div class="chart">${def.props?.data}</div>`
);
// Validate a spec before rendering
const { valid, errors } = validateSpec(spec);{
"root": "card_0",
"elements": {
"card_0": {
"type": "Card",
"props": { "title": "Approval Required" },
"children": ["row_0"]
},
"row_0": {
"type": "Row",
"children": ["btn_ok", "btn_cancel"]
},
"btn_ok": {
"type": "Button",
"props": { "label": "Approve", "variant": "primary" },
"on": { "press": { "action": "approve", "params": { "id": "123" } } }
},
"btn_cancel": {
"type": "Button",
"props": { "label": "Cancel", "variant": "destructive" },
"on": { "press": { "action": "reject" } }
}
}
}getCatalog() returns a snapshot of every catalog-tagged component, ready
to be fed to an LLM as a system prompt or tool definition. The snapshot is
generated from live registry state, so any new registerComponent() call
with { render, catalog } automatically appears in the next snapshot — no
manual sync between render code and the LLM's UI vocabulary.
import { getCatalog } from 'hyper-element/json-render';
const catalog = getCatalog();
// Natural-language system prompt for the LLM. Lists every cataloged
// component with its props (types/required/enum/nullable), children
// capability, and actions, then the { root, elements } output format.
const prompt = catalog.prompt({
customRules: ['Use Card as the root element for any layout'],
});
// JSON Schema tool definition for Claude/OpenAI function calling. The
// `type.enum` lists every cataloged component name.
const tool = catalog.toolDefinition({
name: 'json_render',
description: 'Render interactive UI components',
});Only components registered with a catalog metadata object appear in the
output. Legacy function-only registerComponent(type, fn) registrations
render correctly via <json-render> and renderSpec() but are intentionally
invisible to the LLM — surfacing them would give the LLM types whose props
have no schema.
The returned snapshot is immutable: each catalog entry is deep-cloned and
recursively frozen, and the snapshot instance itself is Object.freezed.
Mutations cannot reach the live registry.
Tag a hyperElement(...) definition with jrType and jrCatalog to
auto-register the custom element as a json-render component in a single
declaration. Whenever a spec references that type, json-render
instantiates your custom element instead of a built-in (or instead of
[unknown: ...] for entirely new types).
import 'hyper-element/json-render';
import { hyperElement } from 'hyper-element';
hyperElement('product-card', {
jrType: 'ProductCard',
jrCatalog: {
description: 'Product display with price and buy action',
props: {
name: { type: 'string', required: true },
price: { type: 'number', required: true },
image: { type: 'string' },
},
slots: [],
actions: {
press: {
description: 'User taps buy',
params: { productId: { type: 'string' } },
},
},
},
render: (Html, ctx) => {
// The bridge serialises def.props as JSON onto data-jr-props.
// The dataset proxy auto-parses it back to a real object on read.
const { name, price, image } = ctx.dataset.jrProps || {};
return Html`
<article class="product-card">
<img src="${image}" alt="${name}" />
<h3>${name}</h3>
<span>$${price}</span>
</article>`;
},
});The bridge transports spec data to your custom element via three attributes:
| Attribute | Source | Read via |
|---|---|---|
data-jr-props |
JSON.stringify(def.props) |
ctx.dataset.jrProps (auto-parsed object) |
data-jr-children |
JSON.stringify(def.children) (raw spec keys) |
ctx.dataset.jrChildren (auto-parsed array) |
data-jr-on |
JSON.stringify(def.on) |
ctx.dataset.jrOn (auto-parsed object) |
Pre-rendered child DOM (from other registered components) is also
appended as light-DOM children at construction time, available for
inspection in setup() before the custom element's first render()
fires.
Interactive bridged components dispatch jr-action CustomEvents
themselves — wire your onclick/onchange/onkeydown handlers to read
the ctx.dataset.jrOn payload and call
element.dispatchEvent(new CustomEvent('jr-action', { bubbles: true, composed: true, detail: { action, params } })).
- Global only.
jrType: 'Card'overrides the built-inCardeverywhere. There is no per-instance opt-out in v1. - Last-write-wins with
console.warnon collision: registering a second component with the samejrTypereplaces the first and emits a warning. The built-in-override path additionally emits the existing warning fromregisterComponent. - Custom types welcome.
jrType: 'MyCustomChart'works without any built-in to override — it simply becomes a new known type. - A
jrTypewithout ajrCatalogregisters the render function only (legacy mode). The component renders correctly via<json-render>andrenderSpec(), but is intentionally invisible togetCatalog()and the LLM schema output.
Interactive components dispatch jr-action CustomEvents that bubble
up from the <json-render> host element. Three idiomatic ways to
subscribe, all exercising the same event channel:
// (1) addEventListener — canonical form for standalone consumers.
document.querySelector('json-render').addEventListener('jr-action', (e) => {
console.log(e.detail.action); // "approve"
console.log(e.detail.params); // { id: "123" }
});
// (2) onaction IDL property — React-style shorthand. Assigning
// replaces the active listener; null removes it.
document.querySelector('json-render').onaction = (e) => {
console.log(e.detail.action, e.detail.params);
};
// (3) Declarative attribute — inside a hyper-element template,
// hyper-element's directFor path lowercases the attribute and
// performs `element.onaction = fn`, routing through the same setter.
Html`
<json-render onaction=${(e) => console.log(e.detail)}>
${JSON.stringify(spec)}
</json-render>`;
// (4) @-prefixed event binding — inside a hyper-element template
// when you prefer the addEventListener channel directly (e.g. to
// handle the same event with multiple host-specific listeners or to
// pass listener options via the array form `@jr-action=${[fn, { capture: true }]}`).
Html`
<json-render @jr-action=${(e) => console.log(e.detail)}>
${JSON.stringify(spec)}
</json-render>`;| Form | Best for | Rebind cost on re-render |
|---|---|---|
addEventListener |
top-level consumers bound once outside any render loop | none — attached manually |
onaction = fn |
imperative flows; React-style spelling inside a template | no rebind when same function reference is passed |
@jr-action=${fn} |
when you want listener options (capture, once, …) via the array form, or just prefer the @ spelling |
no rebind when same function reference is passed (identity short-circuit in event()) |
Fresh inline closures (onaction=${(e) => …} rebuilt per render)
still work correctly — hyper-element swaps the listener on every
render rather than stacking it — but they skip the identity
short-circuit, so expect one remove/add cycle per parent render. If
that matters, hoist the handler to module scope or stash it in the
parent's setup() closure so the reference is stable across renders.
Think of setup() as hyper-element's equivalent of useCallback
without a dependency array — a handler defined there keeps its
identity for the lifetime of the element, so the prev === value
guard in event() skips the rebind on every subsequent render:
hyperElement('my-view', {
setup(ctx) {
ctx.handleAction = (e) => dispatchToBackend(e.detail);
},
render(Html, ctx) {
return Html`
<json-render @jr-action=${ctx.handleAction}>
${JSON.stringify(spec)}
</json-render>`;
},
});When any interactive descendant dispatches jr-action, the
<json-render> host automatically marks itself with
data-jr-busy="true" and data-jr-busy-action="<name>". CSS rules
in json-render.css use that attribute to disable every interactive
descendant — buttons, checklist toggles, text fields — until the
host's textContent is replaced with a new spec (typically the
LLM's response to the action).
This means consumers do not need to:
- track which component was clicked
- manually disable buttons after dispatch
- re-enable controls when the next spec arrives
- wire any per-component "loading" state
The lock auto-engages on dispatch and auto-releases on the next
element.textContent = JSON.stringify(spec) assignment.
const jr = document.querySelector('json-render');
// Forward actions to your backend / LLM
jr.addEventListener('jr-action', async (e) => {
// <json-render> already set data-jr-busy="true" by the time this
// listener runs (capture phase). The user CAN'T click again.
const result = await fetch('/api/action', {
method: 'POST',
body: JSON.stringify({ action: e.detail.action, params: e.detail.params }),
});
const nextSpec = await result.json();
// Replacing textContent triggers re-render and clears data-jr-busy
jr.textContent = JSON.stringify(nextSpec);
});Set data-jr-busy-mode="off" on the host element to disable the
auto-busy behavior. Use this when the consumer wants exclusive
control over interaction state (e.g. selectively disabling only
the dispatching button rather than the whole tree, or surfacing a
custom overlay instead of the CSS-driven lock).
<json-render data-jr-busy-mode="off">{ ...spec... }</json-render>The visual lock is purely CSS — override these selectors in your own stylesheet to customize:
/* Tighten or loosen the dim while busy */
json-render[data-jr-busy] .jr-root {
opacity: 0.5;
}
/* Hide the spinner overlay if you prefer the dim alone */
json-render[data-jr-busy] .jr-btn:not(.loading)::after {
display: none;
}
/* Highlight a specific in-flight action */
json-render[data-jr-busy-action='approve'] .jr-btn {
border-color: var(--jr-success);
}Override CSS custom properties to customize the visual appearance:
:root {
--jr-accent: #3b82f6; /* Primary accent color */
--jr-bg: #1a1a2e; /* Background */
--jr-surface: #16213e; /* Surface elements */
--jr-text: #e4e4e7; /* Primary text */
--jr-radius: 12px; /* Border radius */
}