Generative UI renderer
Renders a JSON spec from the model as chatcn widgets: validated with Zod, drawn while it streams, and safe when the model gets it wrong. Accepts A2UI-style trees too.
On this page
Installation
Usage
Give the model a render_ui tool whose input is a spec, then render the tool part with GenUI. While the call streams, pass the raw input text: the renderer parses it tolerantly and draws what has arrived.
import { GenUI, genuiJsonSchema } from "@/lib/chatcn-genui"
import { genuiFromPart } from "@/hooks/use-chatcn-genui-spec"
// Server: describe the tool with the same schemas the renderer validates against.
const tools = {
render_ui: tool({
description: "Show a chart, comparison, form, calculator or confirmation in the answer.",
inputSchema: jsonSchema(genuiJsonSchema()),
}),
}
// Client: render the tool part where it appears in the message.
function RenderUi({ part }: { part: ChatcnToolPart }) {
const ui = genuiFromPart(part)
return ui ? <GenUI spec={ui.spec} streaming={ui.streaming} onAction={handleAction} /> : null
}
<MessageParts message={message} components={{ tool: RenderUi }} />A spec is one widget, { type, id?, props }, or a stack of widgets with direction, gap and columns. Widget types: chart, stat, stats, comparison, form, calculator, carousel, map, weather, timeline, confirmation, text and button. Their props mirror the components on the pages that follow.
{
"type": "chart",
"props": {
"type": "bar",
"title": "CI build time",
"data": [{ "label": "#1187", "value": 268 }, { "label": "#1188", "value": 251 }],
"unit": "s"
}
}Examples
Streaming spec
While the JSON streams, every field that already parses is kept and the newest item of each array is held back until the next one starts. A number that is still being written never draws a wrong bar, and widgets get streaming so they reserve their final size.
A2UI input
Pass A2UI-style messages (surfaceUpdate, dataModelUpdate, beginRendering) or { components, root }. Layout components become stacks, Text becomes text with usageHint headings, Button sends a button action with its context, { path } bindings read the data model, and component names like Chart or Weather map to our widgets.
Actions
Interactive widgets report back through one onAction callback. Return a promise from confirm and form.submit to show progress on the button; reject it to show the error and keep the widget editable.
| Action | Sent when |
|---|---|
form.submit | A form passes validation. Carries values |
form.cancel | The user dismissed a form |
confirm / cancel | The user confirmed or declined a confirmation card |
select | A comparison option (with selectable) or a carousel card was picked |
calculator.submit | The user sent the calculator's numbers back, with values and results |
button | A button widget or an A2UI button. Carries action and payload |
Validation and errors
- Complete specs are validated strictly. An invalid chart keeps its frame and offers the data as a table; other widgets render the props that did validate and an inline note with the first failing path, like
props.data.3.value: Expected number. - Unknown widget types render a short error listing the supported ones, never a blank gap.
- Each widget sits in its own error boundary, so one broken widget cannot take down the answer around it.
API
| Export | Notes |
|---|---|
GenUI | spec, streaming, onAction, renderers to override or add widget types |
resolveGenUI(input, { streaming }) | Pure resolver from text, object or A2UI to a render tree. Works on the server and in tests |
genuiSchemas | Zod props schema per widget type |
genuiSpecSchema | The full spec schema |
genuiJsonSchema(type?) | JSON Schema for a tool definition, for the whole spec or one widget |
fromA2UI(messages) | A2UI messages or { components, root } to a chatcn spec |
useChatcnGenUISpec, genuiFromPart | Memoized resolving, and reading a spec from a tool or data-genui part |
Accessibility
- Widgets keep their own semantics: charts carry a table alternative, forms label every control, the confirmation card names its status in a live region.
- Streaming widgets set
aria-busyuntil their spec completes.
Motion
- The renderer adds no motion of its own. Widgets reveal new items once as they arrive, and nothing replays on re-render.