Layout & Composition
Arrange fields with Grid, Fields, Section, Spacer, and Break, using responsive colSpan and container queries.
The form builder lays fields out on a 12-column CSS grid. Every layout primitive is exposed on the FormApi object returned by useFormBuilder, so you compose a form's visual structure with the same form.* handle you use to bind fields. There is no separate layout config — you place fields by composing JSX.
Column widths are expressed as a colSpan that resolves against the container's width, not the viewport. The same form definition can render a stacked single-column layout inside a 400px sidebar and a multi-column layout on a wide page, with no viewport media queries involved.
const form = useFormBuilder({formId: "ticket", schema, fields});
<form.Provider>
<form.Fields names={["subject", "priority", "department", "notes"]} />
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>Layout primitives
Every primitive below is a member of the FormApi returned by useFormBuilder. All of them must be rendered inside <form.Provider> so descendant fields can reach the form store.
| Primitive | Purpose | Creates a Grid? |
|---|---|---|
form.Grid | The @container grid grid-cols-12 gap-4 layout root. Establishes the container-query context that colSpan resolves against. | Yes (it is the grid) |
form.Fields | Renders a typed list of field names, each as a form.Field, wrapped in a Grid. The primary composition tool. | Yes |
form.Field | Renders one field by name, with optional per-render colSpan / disabled overrides. Emits only a col-span wrapper <div>. | No |
form.Section | Semantic grouping wrapper — a Card (default) or a bare <fieldset> — each nesting its own inner Grid. | Yes (inner) |
form.Spacer | An empty, aria-hidden grid cell that reserves a column span. | No (lives in a grid) |
form.Break | A full-width, zero-height element that forces the next field onto a new row. | No (lives in a grid) |
form.Field does not create a grid — it only emits a wrapper <div> carrying the resolved col-span classes. A field's colSpan is therefore only meaningful when the field sits inside a Grid, a Fields, or a Section ancestor. Outside a grid the wrapper is benign but the span does nothing. When you hand-compose bare form.Fields, wrap them in <form.Grid>.
ColSpan
A colSpan is either a single number (applied at every breakpoint) or an object keyed by container-query breakpoints. The underlying value type is ColSpanValue — an integer from 0 to 12:
type ColSpanValue = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
type ColSpan =
| ColSpanValue
| {
base?: ColSpanValue;
sm?: ColSpanValue;
md?: ColSpanValue;
lg?: ColSpanValue;
xl?: ColSpanValue;
};The grid has 12 columns, so 12 is full width and 6 is half. 0 maps to hidden. When colSpan is omitted entirely, the field defaults to full width (span 12).
const fields: FormFields<typeof schema> = {
subject: {type: "text", label: "Subject", colSpan: {base: 12, md: 8}},
priority: {type: "select", label: "Priority", colSpan: {base: 12, md: 4}, options},
notes: {type: "textarea", label: "Notes", colSpan: {base: 12}, rows: 3},
};How values map to classes
The base key (and a bare number) emits an unprefixed Tailwind class; only sm, md, lg, and xl carry the container-query @ prefix. The classes are enumerated as static literals so Tailwind's scanner keeps them.
colSpan value | Resulting classes |
|---|---|
4 | col-span-4 |
12 | col-span-12 |
0 | hidden |
undefined | col-span-12 (default full width) |
{base: 12, md: 6} | col-span-12 @md:col-span-6 |
{base: 12, sm: 6, md: 4, lg: 3, xl: 2} | all five breakpoints joined |
Responsive breakpoints
The object form lets a field change width as its container grows. {base: 12, md: 6} means "full width by default; half width once the container reaches the @md breakpoint." Breakpoints follow the standard Tailwind 4 container-query scale (@sm, @md, @lg, @xl) — they are evaluated against the parent Grid's width, not the window.
Because breakpoints are container-relative, a field's layout depends on which grid it lives in. A field with colSpan: {base: 12, md: 6} placed inside a narrow Section stays stacked even on a wide page, because the Section's inner grid is the container being measured.
0 (which maps to hidden) is fine as a constant across breakpoints, but transitioning between hidden (0) and visible (1–12) across breakpoints is undefined behavior in this version. To show or hide a field conditionally, use a visible predicate on the field config instead of a breakpoint that toggles between 0 and a visible span.
Fields — list-driven layout
form.Fields is the quickest way to render a group. Pass the field names; each one renders with the colSpan from its config, inside a single Grid. Names are type-checked against the schema, so a typo is a compile error.
<form.Provider>
<form.Fields names={["subject", "priority", "department", "notes"]} />
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>An empty names array renders an empty grid (no field cells).
Field — single field with per-render overrides
form.Field renders one field. Beyond name, it accepts two per-render overrides that win over the config for this render only — they do not mutate the shared config:
| Prop | Type | Effect |
|---|---|---|
name | schema field name | Which field to render (required). |
colSpan | ColSpan | Overrides the field's configured column span. |
disabled | boolean | Overrides the field's disabled state. |
form.Field only accepts colSpan and disabled as overrides — there is no per-render visible or required prop. Those predicates live on the field config in useFormBuilder({fields}).
Because form.Field does not build a grid, hand-composed fields go inside a form.Grid so their spans resolve:
<form.Grid>
<form.Field name="subject" colSpan={{base: 12}} />
<form.Field name="priority" colSpan={{base: 6}} />
<form.Field name="department" colSpan={{base: 6}} />
</form.Grid>Section — semantic grouping
form.Section groups related fields and nests its own inner Grid, so children's colSpan resolve against the Section's width rather than the outer grid. It has two visual variants via the as prop:
| Prop | Type | Default | Notes |
|---|---|---|---|
titleKey | string | — | i18n key for the heading, resolved through the form's translator. Falls back to the raw string when no translation exists, so a plain label like "Customer details" works as-is. Omit it to render no heading. |
as | "card" | "fieldset" | "card" | card wraps in a shadcn Card with the title in the header; fieldset is a bare <fieldset> with a legend, for use inside tabs or other chrome. |
className | string | — | Extra classes on the outer wrapper (the Card or the fieldset). |
<form.Section titleKey="customer.details.title">
<form.Field name="name" />
<form.Field name="email" />
<form.Field name="phone" />
</form.Section>titleKey is an i18n key. The playground passes human-readable strings directly; that works only because an unresolved key falls back to its raw text. In real forms, prefer a namespaced key such as "customer.details.title".
When as="fieldset", the Section drops the Card chrome and renders a <fieldset> with its title as a legend — ideal inside a tab panel that already provides its own surface:
<TabsContent value="preferences">
<form.Section as="fieldset">
<form.Field name="language" colSpan={{base: 12, md: 6}} />
<form.Field name="newsletter" colSpan={{base: 12, md: 6}} />
</form.Section>
</TabsContent>Spacer & Break — grid flow control
Both Spacer and Break are grid children — render them inside a Grid, Fields, or Section.
form.Spacer reserves an invisible, aria-hidden cell of a given span. Use it to push the next field to a specific column. The span prop is required and accepts the same ColSpan shape as a field.
<form.Grid>
<form.Field name="firstName" colSpan={6} />
<form.Spacer span={6} />
<form.Field name="lastName" colSpan={6} />
</form.Grid>form.Break is a full-width, zero-height element that forces the next field onto a new row regardless of how much horizontal space remains. It takes no props.
<form.Grid>
<form.Field name="city" colSpan={4} />
<form.Field name="postalCode" colSpan={4} />
<form.Break />
<form.Field name="country" colSpan={4} />
</form.Grid>Multi-card and multi-tab composition
A single useFormBuilder instance can be rendered across several disjoint subtrees — separate cards, tab panels, columns — and still submit as one unit. All of them share one provider and one store, so switching tabs does not unmount fields or lose values, and submit collects every field.
The example below puts the submit button outside every Section, lays two Cards side by side with a plain CSS grid, and tucks the remaining fields into tabs. The tab Sections use as="fieldset" because the tab panel already supplies the surface.
<form.Provider>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Submit lives outside every Section subtree.
</span>
<form.SubmitButton alwaysEnabled>Save</form.SubmitButton>
</div>
<div className="grid gap-4 md:grid-cols-2">
<form.Section titleKey="customer.details.title">
<form.Field name="name" />
<form.Field name="email" />
<form.Field name="phone" />
</form.Section>
<form.Section titleKey="customer.billing.title">
<form.Field name="address" />
<form.Field name="city" />
<form.Field name="postalCode" />
</form.Section>
</div>
<Tabs defaultValue="notes">
<TabsList>
<TabsTrigger value="notes">Notes</TabsTrigger>
<TabsTrigger value="preferences">Preferences</TabsTrigger>
</TabsList>
<TabsContent value="notes">
<form.Section as="fieldset">
<form.Field name="notes" colSpan={{base: 12}} />
</form.Section>
</TabsContent>
<TabsContent value="preferences">
<form.Section as="fieldset">
<form.Field name="language" colSpan={{base: 12, md: 6}} />
<form.Field name="newsletter" colSpan={{base: 12, md: 6}} />
</form.Section>
</TabsContent>
</Tabs>
</form.Provider>The outer md:grid-cols-2 here is plain Tailwind on a wrapper div — it positions the two Cards. Inside each Card, the field colSpans resolve against that Card's own inner grid, independently of the outer two-column split.
Container queries in practice
Because colSpan resolves against the parent Grid's container width, the same form renders different layouts at different container sizes with no window resize. Rendering one useFormBuilder instance into two panes of different widths demonstrates this directly — both panes share state, but a {base: 12, md: 6} field stacks in the narrow pane and goes two-up in the wide one.
const fields: FormFields<typeof schema> = {
firstName: {type: "text", label: "First name", colSpan: {base: 12, md: 6}},
lastName: {type: "text", label: "Last name", colSpan: {base: 12, md: 6}},
role: {type: "select", label: "Role", colSpan: {base: 12, md: 4}, options},
bio: {type: "textarea", label: "Bio", colSpan: {base: 12}, rows: 3},
};
const names = ["firstName", "lastName", "role", "bio"] as const;
<form.Provider>
<div className="flex gap-4 items-start">
<div style={{width: "400px"}} className="shrink-0 rounded-md border p-4">
<form.Fields names={names} />
</div>
<div
style={{width: "1000px", resize: "horizontal", minWidth: "300px"}}
className="overflow-auto rounded-md border p-4"
>
<form.Fields names={names} />
</div>
</div>
<form.SubmitButton alwaysEnabled>Save</form.SubmitButton>
</form.Provider>In the 400px pane the {base: 12, md: 6} fields stay full width (below @md); in the wider pane they sit two per row. Typing in either pane updates both, because they bind to the same store.
Quick start
Build the form with useFormBuilder, giving each field a colSpan in its config.
const form = useFormBuilder({
formId: "layouts",
schema,
fields: {
subject: {type: "text", label: "Subject", colSpan: {base: 12, md: 8}},
priority: {type: "select", label: "Priority", colSpan: {base: 12, md: 4}, options},
notes: {type: "textarea", label: "Notes", colSpan: {base: 12}, rows: 3},
},
onSubmit: (values) => save(values),
});Wrap everything in form.Provider and let form.Fields lay the group out from config.
<form.Provider>
<form.Fields names={["subject", "priority", "notes"]} />
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>For richer structure, group fields into form.Section cards or tabs, and reach for form.Grid + form.Field when you need per-render colSpan overrides, Spacers, or Breaks.