Schema Form
A form generated from a zod object schema — one control per field type, required markers and every verdict read from that same schema, typed values on submit, and an explicit notice for shapes it will not guess at.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/schema-form.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SchemaForm" component using zod (types
only — the component never imports zod at runtime) and lucide-react. It turns one
zod object schema into a working form: the schema chooses the fields, their
order, their controls, their required markers and every verdict. There is no
second opinion about validity anywhere in the file.
Contract
- export type SchemaFormWidget = "input" | "textarea" | "select" | "radio" |
"switch" | "checkbox" | "tags"
- export interface SchemaFieldUi { label?: string; placeholder?: string;
description?: string; widget?: SchemaFormWidget; optionLabels?:
Record<string, string | undefined>; fullWidth?: boolean }
- export type SchemaFormUi = Record<string, SchemaFieldUi>
- export interface SchemaFormProps<S extends z.ZodObject> extends
Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit" | "defaultValue" |
"children"> { schema: S; ui?: SchemaFormUi; defaultValues?: Record<string,
unknown>; onSubmit: (values: z.output<S>) => void; validateOnMount?: boolean
(false); columns?: 1 | 2 (1); submitLabel?: string ("Submit"); footer?:
React.ReactNode; disabled?: boolean (false) }
- The component is generic, so it cannot survive forwardRef unchanged: write the
inner render function as (props, ref), wrap it in forwardRef, then cast the
export back to a generic function type. The ref lands on the real <form>.
- defaultValues is deliberately Record<string, unknown> rather than
Partial<z.input<S>>: these are pre-parse seeds, so a date field accepts a Date
or a "YYYY-MM-DD" string, and a number field accepts a number or its text.
- The draft is seeded once, on mount. Swapping schema at runtime is a remount —
document `key={schemaId}` instead of adding a resync effect.
Behavior
- Field discovery: read the object's shape and walk its keys in insertion order,
so the form's layout is the schema's layout. zod types `def` as a union across
every schema kind, so declare a small local interface for the slice you read
(def.type, def.innerType, def.element, def.entries, def.defaultValue,
def.format, .description, _zod.bag) and cast the shape to it once — every read
downstream is then type-checked instead of `any`.
- Wrapper peeling: loop at most 8 times unwrapping def.innerType while def.type
is "optional" (optional = true), "nullable" (nullable = true) or "default"
(optional = true, remember defaultValue). Collect the first .describe() text
found — it may sit on the wrapper or on the leaf. A chain longer than the cap
falls through to the leaf switch and is reported unsupported, so a pathological
schema cannot loop.
- Leaf → control: string → text input (def.format "email" / "url" only switches
the input type as a keyboard hint), number/int → number input with min, max and
step from zod's resolved bounds bag, boolean → role=switch button (or a native
checkbox), enum → native select (or a radio group), date → type=date input,
array of strings → a chips control. Everything else — nested objects, records,
unions, tuples, arrays of anything but strings, enums with non-string members —
renders a role=note "unsupported field" panel naming the shape. It never
vanishes silently, and whatever the consumer seeded for that key is carried
through to submit untouched, so a required nested object supplied from outside
still parses.
- Required is derived, never declared: required = the schema does not accept
undefined AND does not accept null. A `.default()` field is not required (zod
fills it) and a `.nullable()` field is not required (an empty control submits
null), so the star never lies. Render it as a decorative asterisk plus an
sr-only " (required)" and mirror it with aria-required.
- One draft representation per kind, and text is the representation for numbers
and dates too — a half-typed "-" or "1." has to survive keystroke to keystroke,
which a number never can. Booleans are booleans, tags are string arrays.
- One coercion pass turns the draft into parse input, and it is the only place a
string becomes a number or a Date. Empty means "nothing here": undefined when
the schema accepts it (so `.default()` can apply), null when only null is
accepted, and the empty value itself when the schema demands a value — which is
what makes `z.string().min(1)` report instead of the form guessing. Unparseable
number text is handed over as text so zod names it, never as NaN. Dates are
built from local Y/M/D parts and rejected on roll-over ("2026-02-30"), never
routed through Date.parse or toISOString.
- Verdicts: memoise one schema.safeParse of that input, then bucket the issues —
the head of issue.path names the field, a path-less issue (an object-level
.refine) belongs to the form summary. First issue per field wins.
- Reveal is gated on "visited", not on typing: text, number, date and textarea
fields become visited on blur; select, radio, switch, checkbox and the tags
control become visited on change (there is no half-typed state to protect).
validateOnMount marks everything visited up front, which is what an edit form
seeded with a row a tightened schema no longer accepts needs.
- Submit: preventDefault, no-op while disabled, and on success call
onSubmit(result.data) — typed output, never strings. On refusal mark every
field visited, bump a refusal counter, and move focus to the first field that
both has an issue and owns a control (unsupported fields are skipped, so focus
never lands on nothing). The controls are all still mounted — only their error
text is appearing — so the move belongs in the handler, not in an effect.
- The summary is a role=alert re-keyed on the refusal count, so a second refused
attempt is announced again; it disappears the moment the draft parses.
- Tags control: Enter or comma commits (preventDefault, or the form submits and
eats the word), blur commits, Escape clears the pending text and stops
propagating so a surrounding dialog does not close, Backspace on an empty input
removes the last chip, and a duplicate is refused out loud rather than
swallowed. Chips are keyed by value, which is what lets a removal hand focus to
the next chip's remove button synchronously — that button is already in the
document and stays there across the re-render — with the last chip handing back
to the text input. Focus never reaches <body>.
- `disabled` uses aria-disabled plus readOnly and handler guards, never the
native disabled attribute: the browser blurs a node the instant it goes
disabled, and this prop can flip while the user is inside a field. A controlled
select or radio simply snaps back.
- Cleanup: there is nothing to clean. No timers, no rAF, no listeners, no
observers, no effects at all — the only ref work is a synchronous read in a
handler. Keep it that way.
- The clock is never read. Date fields are pure text in, Date out; bounds belong
in the schema.
Rendering & styling
- Semantic tokens only: controls are `rounded-md border bg-background` with
`focus-visible:ring-2 focus-visible:ring-ring`, invalid draws
`aria-invalid:border-destructive`, labels `text-sm font-medium`, descriptions
and the tags status line `text-xs text-muted-foreground`, errors
`text-xs text-destructive`, chips `bg-muted`, the unsupported panel
`border-dashed bg-muted/40`, the summary `border-destructive/40 bg-destructive/5`,
submit `bg-primary text-primary-foreground`. No hex, rgb or oklch anywhere.
- cn() merges the consumer's className onto the <form>; the remaining form props
spread onto it, with noValidate, onSubmit and ref applied afterwards so the
browser can never race the schema.
- Layout is a container query, not a viewport one: the form is an `@container`
and `columns={2}` becomes two tracks past 24rem, so the same form stacks inside
a narrow drawer. Only emit `col-span-2` when columns is 2 — a span inside a
single-column grid conjures an implicit second track and drags the other fields
into it. Textareas, radio groups and tags fields take the span automatically.
- Accessibility: every control has a real <label htmlFor>, except the radio group
which is a fieldset + legend wired with aria-labelledby, and booleans which put
the control first and the label beside it. aria-describedby chains description
then error. Errors animate in through one hoisted @keyframes with a
motion-reduce escape; nothing about the form depends on that animation.
- Keyboard map: Tab walks fields in schema order; arrows rove inside a radio
group (native); Space or Enter flips the switch; Enter in any single-line input
submits, except inside the tags input where it commits a chip; Backspace on an
empty tags input removes the last chip; Escape clears pending chip text.
Customization levers
- Presentation: everything user-visible is in the ui map — label, placeholder,
description, optionLabels, fullWidth — and none of it can change a verdict.
Pass description: "" to drop a schema's own .describe() text.
- Control choice: widget forces textarea for long strings, radio for short enums,
checkbox instead of the switch. A widget the field's kind has no version of
falls back to that kind's default; document this rather than hiding it.
- Density: fields sit in a `gap-5` grid with `h-9` controls — drop to gap-3 /
h-8 for admin panels, raise to gap-6 / h-10 for marketing forms.
- Layout: columns={2} plus per-field fullWidth is the whole layout API on
purpose. For anything richer, render the fields yourself and keep this for the
long tail.
- Supported subset: adding a kind is one branch in the leaf switch plus one draft
representation plus one coercion case — do all three or the value will not
round-trip. Everything else must stay in the unsupported branch.
- Copy: submitLabel, the tags placeholder, the "Select…" option, the unsupported
sentence and the summary sentence are the only English strings in the file —
lift them into props for i18n.
- Deliberately not done: nested paths, arrays of objects (pair with a repeater
field), async or server-side verdicts, and cross-field refinements beyond
showing what zod already reports.Concepts
- Schema as the single judge — the same object that generated the controls runs every verdict, on blur and on submit. The form owns no rules of its own, so tightening
z.string()toz.string().min(3)changes the message with no edit to the form. - Wrapper peeling —
.optional(),.nullable()and.default()are unwrapped before the leaf picks a control. That pass is what makes the required star honest: a defaulted field is filled by zod and a nullable field submitsnull, so neither is marked required. - Draft text, typed output — numbers and dates live in the draft as text so a half-typed
-or1.survives, and exactly one coercion pass turns the whole draft into parse input. What comes back out ofonSubmitis anumber, aboolean, aDate— never the string the user typed. - Refusal over silent drop — a nested object, a record or an array of numbers gets a visible "unsupported field" panel naming the shape, and its seeded value is still carried into the submitted object. A generated form that quietly omits a field is worse than one that admits its limits.
- Visited-gated reveal — a message appears only once the field has been left (text) or changed (choices), or once a submit has been refused.
validateOnMountflips everything visited up front for edit forms whose stored row no longer satisfies the contract. - Focus that never lands on nothing — a refused submit moves focus to the first field that has both an issue and a control; removing a chip hands focus to the next chip, or back to the text input. The
disabledprop isaria-disabledplus handler guards, so going inert never yanks the caret out to<body>.
Transfer List
A dual listbox: two multi-selectable panes with per-side search and counts, move / move-all buttons between them, and a keyboard move that reports what travelled and where focus landed.
Font Picker
A typeface field where every option is rendered in its own face — grouped sans / serif / mono / display, previewed with your own pangram, with system stacks always present and remote families fetched through an injected loader that reports loading and failure per font.