---
url: https://docs.tailor.tech/app-shell/patterns/form-sectioned.md
description: Complex form with 15+ fields organized into named fieldset sections
---

# 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.Root` per section, titled via `Card.Header title` + `description`; `Fieldset.Root`
  inside supplies the field grouping and the responsive grid
* Save/Cancel belong in `Layout.Header`, wired with `<Button type="submit" form="…">` matching the
  `Form`'s `id` (requires 1.13.0+)
* Section headings must match anchor-nav labels — derive both from one `SECTIONS` array so they
  cannot drift
* A single `<Form>` wraps every section — do not nest a `Form` per 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 `useState` to "keep the payload together" — `onFormSubmit` already
  returns the whole payload
* Submitting an object-valued dropdown without `itemToStringValue` — the value arrives as JSON
