Sectioned Form
When to Use
- Form is complex with 15+ fields or multiple grouped sections (Identity, Pricing, Inventory)
- Configure-style settings pages with named boundaries
Page Implementation
tsx
function FormSectioned() {
const onSave = (values: Record<string, unknown>) =>
window.alert(`Saving ${Object.keys(values).length} fields`);
const onCancel = () => {};
return (
<Layout>
<Layout.Header
title="Product settings"
actions={[
<Button key="cancel" variant="outline" onClick={onCancel}>
Cancel
</Button>,
<Button key="save" type="submit" form="product-settings-form">
Save
</Button>,
]}
/>
<Layout.Column>
<Form
id="product-settings-form"
noValidate
className="space-y-4"
onFormSubmit={(values) => onSave(values)}
>
{/* Anchor nav — each entry targets its section's Card by id. */}
<nav aria-label="Sections" className="flex gap-4 text-sm">
{SECTIONS.map((section) => (
<a
key={section.id}
href={`#${anchorIdFor(section.id)}`}
className="text-muted-foreground hover:text-foreground"
>
{section.title}
</a>
))}
</nav>
<Card.Root id={anchorIdFor("identity")}>
<Card.Header title={SECTIONS[0].title} description={SECTIONS[0].description} />
<Card.Content>
<Fieldset.Root className="grid gap-4 md:grid-cols-2">
<Field.Root name="name">
<Field.Label>Name</Field.Label>
<Field.Control required />
<Field.Error match="valueMissing">Name is required.</Field.Error>
</Field.Root>
<Field.Root name="sku">
<Field.Label>SKU</Field.Label>
<Field.Control required />
<Field.Error match="valueMissing">SKU is required.</Field.Error>
</Field.Root>
<Field.Root name="description" className="md:col-span-2">
<Field.Label>Description</Field.Label>
<Field.Control />
</Field.Root>
</Fieldset.Root>
</Card.Content>
</Card.Root>
<Card.Root id={anchorIdFor("pricing")}>
<Card.Header title={SECTIONS[1].title} description={SECTIONS[1].description} />
<Card.Content>
<Fieldset.Root className="grid gap-4 md:grid-cols-2">
<Field.Root name="price">
<Field.Label>Price</Field.Label>
<Field.Control type="number" required min={0} step="0.01" />
<Field.Error match="valueMissing">Price is required.</Field.Error>
</Field.Root>
<Field.Root name="currency">
<Field.Label>Currency</Field.Label>
<Combobox items={CURRENCIES} defaultValue="USD" placeholder="Select currency" />
<Field.Error />
</Field.Root>
</Fieldset.Root>
</Card.Content>
</Card.Root>
<Card.Root id={anchorIdFor("inventory")}>
<Card.Header title={SECTIONS[2].title} description={SECTIONS[2].description} />
<Card.Content>
<Fieldset.Root className="grid gap-4 md:grid-cols-2">
<Field.Root name="quantity">
<Field.Label>Initial quantity</Field.Label>
<Field.Control type="number" required min={0} />
<Field.Error match="valueMissing">Initial quantity is required.</Field.Error>
</Field.Root>
<Field.Root name="reorderPoint">
<Field.Label>Reorder point</Field.Label>
<Field.Control type="number" min={0} />
<Field.Description>
Leave blank to disable replenishment alerts.
</Field.Description>
</Field.Root>
{/*
* Object items would otherwise reach `onFormSubmit` as JSON.
* `itemToStringValue` picks the field to submit; `mapItem`
* stays responsible for what the user sees.
*/}
<Field.Root name="warehouse">
<Field.Label>Default warehouse</Field.Label>
<Combobox
items={WAREHOUSES}
mapItem={(w) => ({ label: w.name, key: String(w.id) })}
itemToStringValue={(w) => String(w.id)}
placeholder="Select warehouse"
/>
</Field.Root>
</Fieldset.Root>
</Card.Content>
</Card.Root>
</Form>
</Layout.Column>
</Layout>
);
}Constraints
- Max ~6 sections — more than that is too hard to scan; promote to
form/wizard - Required-marker convention must be consistent across all sections
- One
Card.Rootper section, titled viaCard.Header title+description;Fieldset.Rootinside supplies the field grouping and the responsive grid - Save/Cancel belong in
Layout.Header, wired with<Button type="submit" form="…">matching theForm'sid(requires 1.13.0+) - Section headings must match anchor-nav labels — derive both from one
SECTIONSarray so they cannot drift - A single
<Form>wraps every section — do not nest aFormper section
Form state
Rules that apply to every form/* pattern are in components.md → Forms, with a worked example in form/modal → Form state: Form + Field is the default stack, submit via onFormSubmit, dropdowns need only a wrapping Field.Root (no name, no state), server errors route through errors, and React Hook Form is an optional escape hatch.
At 15+ fields the temptation to reach for a form library is strongest, and it is usually wrong: every field here — text, number, and dropdown alike — is uncontrolled and arrives through onFormSubmit. The only per-field extra is itemToStringValue on dropdowns whose items are objects rather than strings.
Anti-patterns
- More than ~6 sections — hard to scan; promote to
form/wizard - Required-marker convention varies between sections — pick one rule and apply everywhere
- Section headings that don't match anchor-nav labels
- A separate
<Form>per section — submit and validation then fragment across the page - Mirroring field values into
useStateto "keep the payload together" —onFormSubmitalready returns the whole payload - Submitting an object-valued dropdown without
itemToStringValue— the value arrives as JSON