Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CircleAlert, TriangleAlert, X } from "lucide-react"
import type { z } from "zod"
import { cn } from "@/lib/utils"

/** Error entrance. React 19 hoisted <style> — no Tailwind config edits. */
const KEYFRAMES = `@keyframes sf-msg-in{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:none}}`

/** How many optional / nullable / default wrappers are peeled before a field is called unsupported. */
const MAX_UNWRAP = 8

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/schema-form.json

Prompt

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() to z.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 submits null, so neither is marked required.
  • Draft text, typed output — numbers and dates live in the draft as text so a half-typed - or 1. survives, and exactly one coercion pass turns the whole draft into parse input. What comes back out of onSubmit is a number, a boolean, a Date — 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. validateOnMount flips 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 disabled prop is aria-disabled plus handler guards, so going inert never yanks the caret out to <body>.

On This Page