Skip to content
View as Markdown

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