Defining Components
Define OpenUI components with Zod and React renderers.
Use defineComponent(...) to register each component and createLibrary(...) to assemble the library.
Core API
import { defineComponent, createLibrary } from "@openuidev/react-lang";
import { z } from "zod/v4";
const StatCard = defineComponent({
name: "StatCard",
description: "Displays a metric label and value.",
props: z.object({
label: z.string(),
value: z.string(),
}),
component: ({ props }) => (
<div>
<strong>{props.label}</strong>
<div>{props.value}</div>
</div>
),
});
export const myLibrary = createLibrary({
root: "StatCard",
components: [StatCard],
});If you want one import path that works with both zod@3.25.x and zod@4, use import { z } from "zod/v4" for OpenUI component schemas.
Required fields in defineComponent
name: component call name in OpenUI Lang.props:z.object(...)schema. Key order defines positional argument order.description: used in prompt component signature lines.component: React renderer receiving{ props, renderNode }.
Nesting pattern with .ref
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod/v4";
const Item = defineComponent({
name: "Item",
description: "Simple item",
props: z.object({ label: z.string() }),
component: ({ props }) => <div>{props.label}</div>,
});
const List = defineComponent({
name: "List",
description: "List of items",
props: z.object({
items: z.array(Item.ref),
}),
component: ({ props, renderNode }) => <div>{renderNode(props.items)}</div>,
});Union multiple component types pattern
To define container components that accepts multiple child components, you can use the z.union function to define the child components.
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod/v4";
const TextBlock = defineComponent({/* ... */});
const CalloutBlock = defineComponent({/* ... */});
const TabItemSchema = z.object({
value: z.string(),
trigger: z.string(),
content: z.array(z.union([TextBlock.ref, CalloutBlock.ref])),
});Action props
Use action() for a prop that takes an action, such as a button's click behavior:
import { action, defineComponent, useTriggerAction } from "@openuidev/react-lang";
import { z } from "zod/v4";
const Button = defineComponent({
name: "Button",
description: "Triggers an action",
props: z.object({
label: z.string(),
action: action().optional(),
}),
component: ({ props }) => {
const triggerAction = useTriggerAction();
return (
<button onClick={() => triggerAction(props.label, undefined, props.action)}>
{props.label}
</button>
);
},
});The prompt prints the prop as action?: ActionExpression and teaches the model the available actions. See Actions.
Naming reusable helper schemas
Use tagSchemaId(...) when a prop uses a standalone helper schema and you want a readable name in generated prompt signatures.
import { defineComponent, tagSchemaId } from "@openuidev/react-lang";
import { z } from "zod/v4";
const ValidationRules = z.record(z.string(), z.any());
tagSchemaId(ValidationRules, "ValidationRules");
const Input = defineComponent({
name: "Input",
description: "A text input",
props: z.object({
name: z.string(),
rules: ValidationRules.optional(),
}),
component: ({ props }) => <input name={props.name} />,
});The prompt prints rules?: ValidationRules instead of rules?: Record<string, any>. The tag changes the prompt only, not the JSON Schema. Components already get their names automatically through defineComponent(...), so this is only needed for non-component helper schemas.
Functions and actions
A library can also add its own @ functions and actions:
const library = createLibrary({
root: "Stack",
components: [Stack, Card, TextContent, Button],
functions: [percent], // defineFunction(...)
actions: [copyToClipboard], // defineAction(...)
});See Functions and Actions. To add to an existing library instead, see Extending a library.
The root field
The root option in createLibrary specifies which component the LLM must use as the entry point. The generated system prompt instructs the model to always start with root = <RootName>(...).
const library = createLibrary({
root: "Stack", // → prompt tells LLM: "every program must define root = Stack(...)"
components: [Stack, Card, TextContent],
});This serves two purposes:
- Constrains the LLM: the model always wraps its output in a known top-level component, making output predictable.
- Enables streaming: because the root statement comes first, the UI shell renders immediately while child components stream in.
The root must match the name of one of the components in your library. If omitted, the prompt uses "Root" as a placeholder.
The root component also decides the fallback when a program has no root statement. See Entry Statement.
For the built-in libraries: openuiLibrary uses Stack (flexible layout container), while openuiChatLibrary uses Card (vertical container optimized for chat responses).
Notes on schema metadata
- Positional mapping is driven by Zod object key order.
- Required/optional state is used by parser validation.
Grouping components in prompt output
const library = createLibrary({
root: "Stack",
components: [/* ... */],
componentGroups: [
{ name: "Forms", components: ["Form", "FormControl", "Input", "Button", "Buttons"] },
],
});Why group components?
componentGroups organize the generated system prompt into named sections (e.g., Layout, Forms, Charts). This helps the LLM locate relevant components quickly instead of scanning a flat list. Without groups, all component signatures appear under a single "Ungrouped" heading.
Groups also let you co-locate related components so the LLM understands which components work together (e.g., Form with FormControl, Input, Select).
Adding group notes
Each group can include a notes array. These strings are appended directly after the group's component signatures in the generated prompt. Use notes to give the LLM usage hints and constraints:
componentGroups: [
{
name: "Forms",
components: ["Form", "FormControl", "Input", "TextArea", "Select"],
notes: [
"- Define EACH FormControl as its own reference for progressive streaming.",
"- NEVER nest Form inside Form.",
"- Form requires explicit buttons: Form(name, buttons, fields).",
],
},
{
name: "Layout",
components: ["Stack", "Tabs", "TabItem", "Accordion", "AccordionItem"],
notes: [
'- For grid-like layouts, use Stack with direction "row" and wrap=true.',
],
},
],Notes appear in the prompt output like this:
### Forms
Form(id: string, buttons: Buttons, controls: FormControl[]) — Form container
FormControl(label: string, field: Input | TextArea | Select) — Single field
...
- Define EACH FormControl as its own reference for progressive streaming.
- NEVER nest Form inside Form.
- Form requires explicit buttons: Form(name, buttons, fields).Prompt options
When generating the system prompt, you can pass PromptOptions to customize the output further:
import type { PromptOptions } from "@openuidev/react-lang";
const options: PromptOptions = {
preamble: "You are an assistant that outputs only OpenUI Lang.",
additionalRules: ["Always use Card as the root for chat responses."],
examples: [`root = Stack([title])\ntitle = TextContent("Hello", "large-heavy")`],
};
const prompt = library.prompt(options);See System Prompts for full details on prompt generation.
Extending a library
library.extend(...) derives a new library from an existing one, such as openuiChatLibrary from @openuidev/react-ui. The base library is not changed.
export const shopLibrary = openuiChatLibrary.extend({
components: {
add: [{ component: ProductCard, slots: ["Card.children"] }],
override: [CodeBlock],
remove: ["RadarChart"],
},
functions: { add: [formatCurrency] },
actions: { add: [addToCart] },
});addtakes new components.slotsputs one in a parent's signature, as"Parent.prop".- The shorthand
"Card"works when the parent has exactly one content slot. overridereplaces a component of the same name.removedrops it from the library and from every parent.- Create the derived library once, at module scope. A new library on every render makes the
Rendererparse again from the start.
Best practices for LLM generation
Since LLMs are the ones writing OpenUI Lang, component design choices directly affect generation quality.
Keep schemas flat
Deeply nested object props burn tokens and increase error rates. Prefer multiple simple components over one deeply nested one.
Order Zod keys deliberately
Required props first, optional props last. The most important or distinctive prop should be position 0, since the LLM sees it first during generation.
Use descriptive component names
The LLM picks components by name. PricingTable is clearer than Table3. The description field reinforces this.
Limit library size
Every component adds to the system prompt. Include only components the LLM actually needs for the use case. Fewer components means less confusion and better output.
Use .ref for composition, not deep nesting
z.array(ChildComponent.ref) is the idiomatic way to compose. The LLM generates each child as a separate line, which streams and validates independently.
Provide examples in PromptOptions
One or two concrete examples dramatically improve output quality, especially for complex or unusual component shapes. See System Prompts for details.
Use componentGroups with notes
Group related components and add notes like "Use BarChart for comparisons, LineChart for trends" to guide the LLM's choices. See Grouping components above.