There are two different jobs here.
| Job | The model’s prompt | Use |
|---|---|---|
Swap a renderer: draw Card with your own component |
Does not change | ui.extend or components |
| Add a component: teach the model something new | Gains one signature line | defineComponent + createLibrary |
Swap a renderer
The schema stays, so the model writes exactly what it wrote before. Only what is drawn changes.
import type { ComponentProps } from "@gistui/react";
import { ui } from "@gistui/react/ui";
function MyCard({ props, children }: ComponentProps) {
return <section className="my-card" data-variant={props.v}>{children}</section>;
}
const lib = ui.extend({ Card: MyCard });
<GistUI library={lib} stream={stream} />;<script setup lang="ts">
import { GistChildren, type GistUIComponentProps } from "@gistui/vue";
defineProps<GistUIComponentProps>();
</script>
<template>
<section class="my-card"><GistChildren /></section>
</template><GistUI :source="answer" :components="{ Card: MyCard }" /><script lang="ts">
import { GistChildren, type GistUIComponentProps } from "@gistui/svelte";
let { props }: GistUIComponentProps = $props();
</script>
<section class="my-card"><GistChildren /></section><GistUI source={answer} components={{ Card: MyCard }} />import { GistChildren, type GistUIComponentProps } from "@gistui/solid";
const MyCard = (p: GistUIComponentProps) => <section class="my-card"><GistChildren /></section>;
<GistUI source={answer()} components={{ Card: MyCard }} />;import { h } from "@gistui/vanilla";
import { ui } from "@gistui/vanilla/ui";
const lib = ui.extend({
Card: (ctx) => {
const el = h("section", { class: "my-card" });
ctx.place(el); // children go here
return { el, update: () => true };
},
});Your component receives props (what the model wrote, already validated and coerced), node and its children.
Form fields and buttons of your own
A field of your own should still be part of the Form around it: validated by its rules, showing its error, bound to bind:$var. One helper per framework does that.
| Framework | Field | Button |
|---|---|---|
| React | useGistField(props) |
useGistButton(props) |
| Vue | useGistField(props) |
|
| Svelte | gistField(() => p.ctx) |
|
| Solid | createGistField(props) |
|
| Vanilla | formField(ctx, onChange) |
import { useGistButton, useGistField } from "@gistui/react/ui";
function MyInput(p: ComponentProps) {
const field = useGistField(p); // name, label, required, error, locked, value, setValue
return (
<label>
{field.label}
<input name={field.name} value={field.value ?? ""} onChange={(e) => field.setValue(e.target.value)} disabled={field.locked} aria-invalid={!!field.error} />
{field.error && <small>{field.error}</small>}
</label>
);
}
function MyButton(p: ComponentProps) {
const b = useGistButton(p); // do: actions, submit and draft in forms, opens: dialogs, links
return <><button {...b.buttonProps}>{b.label}</button>{b.dialog}</>;
}Render a native control with name (Radix and most libraries render a hidden one), so the form can read it.
Add a new component
Define its schema and its renderer, and build a library that includes it.
import { createLibrary, defineComponent } from "@gistui/react";
import { ui } from "@gistui/react/ui";
const Rating = defineComponent({
name: "Rating",
group: "Content",
description: "star rating, 0 to 5",
args: ["value"],
props: {
value: { type: "number", required: true },
label: { type: "string" },
size: { type: "enum", values: ["sm", "md", "lg"], default: "md" },
},
component: ({ props }) => <Stars value={props.value} label={props.label} size={props.size} />,
});
const lib = createLibrary({ components: [...ui.components, Rating] });The prompt now includes Rating(value:num, label:str, size:sm|md|lg) — star rating, 0 to 5, and the parser accepts it. Props can also be a Zod 4 schema or any Standard JSON Schema.
Build the prompt from the same library on the server, so the model and the parser agree:
const system = lib.prompt().text;Components that take a table
Column-oriented components (a BarChart(labels, Series…) in shadcn or MUI style) can still take one pipe table from the model. Add a table mapping to the spec:
table: { labels: "labels", columns: { component: "Series", label: "category", values: "values", numeric: true } }BarChart(sales) with sales = |Week|Web|Store then renders exactly as the parts written out, for a fraction of the tokens.
With a component library
Step-by-step pages for shadcn/ui, shadcn-vue, shadcn-svelte, Radix, Reka UI, Bits UI, Kobalte and MUI are in UI libraries.
Checklist
- Keep the name when you swap, so the prompt does not change.
- Read only
props: they are validated. Do not parse the raw source. - Treat every string in
propsas untrusted text. Do not insert it as HTML. - For a URL that loads by itself (an image, a video), give the prop
format: "url"so the host policy applies. See Security.