# Forms

Lay out fields, labels, and actions so you can fill in and submit a form in one pass.

## Overview

Forms collect values such as a payment amount, a webhook URL, or an API key name. When every Tempo product places labels, hints, errors, and actions in the same spots, you learn the layout once.

This page covers layout, required fields, action placement, and when changes save. [Errors and Validation](/docs/patterns/errors-and-validation) covers checking values and reporting problems.

## Components

| Component                                    | Role in the form                                                        |
| -------------------------------------------- | ----------------------------------------------------------------------- |
| `Field`                                      | Links a label, description, and error to one control.                   |
| `Fieldset`                                   | Groups related fields under a legend and passes its `variant` to them.  |
| `Input`, `AmountInput`, `Textarea`, `Select` | Collect text, amounts, longer text, and one choice from a list.         |
| `Checkbox`, `RadioGroup`, `Switch`           | Collect yes or no answers, one choice from a few, and instant settings. |
| `Button`                                     | Submits or cancels the form.                                            |
| `Surface` and `Frame`                        | Hold the form as a card with a title, a body, and a footer for actions. |

## Structure

1. A title that names the task, such as New webhook, and a description of what happens when you submit.
2. Fields in one column, in the order you answer them. Related fields share a `Fieldset`.
3. Each field reads top to bottom: label, control, then a description or an error.
4. Actions after the last field, with Cancel before the primary action.

```tsx
import { Button, Checkbox, Field, Fieldset, Frame, Input, Stack, style, Surface, Textarea, Toast } from '@tempoxyz/ds'
import { useState } from 'react'

export const fill = true
const events = [
  ['payment.received', 'Payment received'],
  ['payment.sent', 'Payment sent'],
  ['transfer.failed', 'Transfer failed'],
] as const

function valid(url: string) {
  return /^https:\/\/[^\s/]+\.[^\s]+$/.test(url)
}

function WebhookForm() {
  const toast = Toast.useToast()
  const [invalid, setInvalid] = useState(false)
  const [pending, setPending] = useState(false)
  const [submitted, setSubmitted] = useState(false)
  const [url, setUrl] = useState('')

  return (
    <Surface
      {...styles.form()}
      render={
        <form
          noValidate
          onSubmit={(event) => {
            event.preventDefault()
            setSubmitted(true)

            if (!valid(url)) {
              setInvalid(true)
              event.currentTarget.querySelector<HTMLInputElement>('input[name="url"]')?.focus()
              return
            }

            setPending(true)
            setTimeout(() => {
              setPending(false)
              toast.add({ description: url, intent: 'positive', title: 'Webhook created' })
            }, 1200)
          }}
        />
      }
    >
      <Frame.Root>
        <Frame.Header>
          <Frame.Title>New webhook</Frame.Title>
          <Frame.Description>
            Tempo sends a POST request to your endpoint for each event you choose.
          </Frame.Description>
        </Frame.Header>
        <Fieldset.Root disabled={pending}>
          <Fieldset.Legend>Endpoint</Fieldset.Legend>
          <Field.Root invalid={invalid}>
            <Field.Label>Endpoint URL</Field.Label>
            <Input.Group>
              <Input.Control
                autoComplete="off"
                name="url"
                onValueChange={(value) => {
                  setUrl(value)
                  // After the first submit, the error clears as soon as the value is fixed.
                  if (submitted) setInvalid(!valid(value))
                }}
                placeholder="https://api.example.com/tempo"
                required
                spellCheck={false}
                type="url"
                value={url}
              />
            </Input.Group>
            <Field.Description>Use an HTTPS URL on a server you control.</Field.Description>
            <Field.Error match={invalid}>Enter a URL that starts with https://.</Field.Error>
          </Field.Root>
          <Field.Root>
            <Field.Label>Description (optional)</Field.Label>
            <Textarea name="description" placeholder="Order fulfillment" rows={2} />
          </Field.Root>
        </Fieldset.Root>
        <Fieldset.Root disabled={pending}>
          <Fieldset.Legend>Events</Fieldset.Legend>
          <Stack gap="3">
            {events.map(([value, label]) => (
              <Field.Root key={value}>
                <Field.Item>
                  <Checkbox defaultChecked={value === 'payment.received'} name={value} />
                  <Field.Label>{label}</Field.Label>
                </Field.Item>
              </Field.Root>
            ))}
          </Stack>
        </Fieldset.Root>
        <Frame.Footer>
          <Frame.Actions {...styles.actions()}>
            <Button type="button" variant="secondary">
              Cancel
            </Button>
            <Button loading={pending} type="submit">
              Create webhook
            </Button>
          </Frame.Actions>
        </Frame.Footer>
      </Frame.Root>
    </Surface>
  )
}

export function Demo() {
  return (
    <div {...styles.page()}>
      <Toast.Provider>
        <WebhookForm />
        <Toast.Viewport />
      </Toast.Provider>
    </div>
  )
}

namespace styles {
  // Below 640px the actions stack at full width with the primary action on top.
  export const actions = style({
    flexDirection: 'column-reverse',
    rowGap: '2',
    selectors: { '& > *': { width: '100% !custom' } },
    '@media (min-width: 40rem)': {
      flexDirection: 'row',
      selectors: { '& > *': { width: 'auto !custom' } },
    },
  })

  export const form = style({
    boxSizing: 'border-box',
    maxWidth: '30rem !custom',
    width: '100% !custom',
  })

  export const page = style({
    backgroundColor: 'background.primary',
    display: 'grid',
    justifyItems: 'center',
    padding: '4',
  })
}
```

[View example source](https://github.com/tempoxyz/ds/blob/main/docs/src/examples/demos/Patterns/WebhookForm.tsx)

This is a reference layout built from `Surface`, `Frame`, `Fieldset`, and `Field`, not a shipped component. Submit it with an empty URL to see the error, then fix the URL to watch the error clear.

## Guidelines

### Use One Column

Stack fields in a single column so you read straight down and never skip a field. Put two fields on one row only when they form a single short value, such as a month and a year.

**Do:** Stack fields in one column, in the order you answer them.

**Don't:** Arrange unrelated fields in a grid, where the reading order is unclear.

### Put Labels Above Fields

The `default` `Field.Root` variant places `Field.Label` above the control. The label stays visible while you type, and long labels and translations wrap without squeezing the control.

* Use `stacked`, which floats the label inside the control, in short flows with little room, such as a two-field sheet.
* Use `setting` for a row with a trailing `Switch`, such as a notification preference.
* Use `card` for a large amount field on a send screen.

**Do:** Give every control a visible `Field.Label`, with format hints in `Field.Description`.

**Don't:** Use a placeholder as the label. It disappears as soon as you type.

### Group Related Fields

Wrap related fields in `Fieldset.Root` with a `Fieldset.Legend`, such as Endpoint or Events. A fieldset stacks its fields 1.5rem apart and passes its `variant` to every nested `Field.Root`.

Write the legend as the topic or question the group answers. Don't nest fieldsets. When a form outgrows two or three groups, split it into steps with a `Stepper`.

### Mark Optional Fields

Ask only for what you need, so most fields are required. Mark the exceptions by adding (optional) to the label, such as Memo (optional). Don't add an asterisk to required fields.

Set `required` on required controls anyway, so assistive technology announces them. Set `noValidate` on the `<form>` so the browser's own validation popups don't replace `Field.Error`.

**Do:** Write (optional) in the label of each optional field.

**Don't:** Mark required fields with an asterisk and leave optional fields unmarked.

When every field is optional, such as on a filter form, say so once in the description instead of in each label.

### Place Actions at the End

These rules match the action order on the [Button](/docs/components/button) page.

* Put the actions after the last field, aligned to the end, with the primary action last.
* Put Cancel or Back before the primary action, as a `secondary` button.
* Below 640px, stack the actions at full width with the primary action first.
* Use one `primary` button and set `type="submit"` on it, so Enter in a text field submits the form.
* Label the submit button with its outcome, such as Create webhook, not Submit.

**Do:** Align actions to the end, with Cancel before the primary action.

**Don't:** Put the primary action first and push Cancel to the far edge.

### Choose a Save Model

| Model          | Use for                                                        | Behavior                                                                                                     |
| -------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Save on submit | Values that work together, such as Send payment or New webhook | Nothing changes until you press the primary action. Use `Checkbox` for choices in these forms.               |
| Save on change | Independent settings, such as Email receipts                   | Each `Switch` applies the moment you flip it. Confirm with a `Toast`, and flip it back if the request fails. |

Don't mix the two in one section. A `Switch` beside a Save button leaves you unsure whether the setting already applied.

When you leave a long form with unsaved changes, ask before discarding them with an `AlertDialog`. See [Destructive Actions](/docs/patterns/destructive-actions).

### Help Browsers Fill Values

Set `type`, `inputMode`, and `autoComplete` on each `Input.Control`, so browsers suggest saved values and phones show the right keyboard. Use `type="email"` with `autoComplete="email"` for an email address.

For addresses, API keys, and other machine values, set `autoComplete="off"` and `spellCheck={false}`. Use `AmountInput` for amounts, which sets `inputMode="decimal"` and rejects anything that isn't a decimal number.

## States

| State    | What you show                                                                                                                                |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Default  | Labelled fields, prefilled when the value is known, such as the connected address. The primary action is enabled.                            |
| Loading  | While saved values load, wrap each control in a `Skeleton`. While the form submits, set `loading` on its submit button.                      |
| Error    | A `Field.Error` under each invalid field, and a summary above long forms. See [Errors and Validation](/docs/patterns/errors-and-validation). |
| Empty    | A new form starts with empty controls. Use placeholders only for format examples, such as `0x…` or `https://`.                               |
| Disabled | Set `disabled` on `Fieldset.Root` to disable a whole group, such as while the form submits. Say why in nearby text when it stays disabled.   |
| Success  | Close the form or return to the list, and confirm with a `Toast`, such as Webhook created.                                                   |

Set `success` on a `Field.Root` only for a value the network verified, such as a recipient address that resolved. It adds a green tint and ring but no ARIA state, so confirm in text as well.

## Accessibility

* `Field.Label` renders a native `<label>` linked to its control, so pressing the label focuses the control.
* Base UI adds `Field.Description` and a shown `Field.Error` to the control's `aria-describedby`, so both are read with the control.
* `Fieldset.Root` renders a native `<fieldset>`. `Fieldset.Legend` renders a `div` that names it through `aria-labelledby`.
* Keep the visual order of fields the same as the DOM order, so Tab moves down the form one field at a time.
* Mark optional fields in visible text, and set `required` on required controls.
* Set `autoComplete` on fields for personal data, such as name and email, to meet WCAG 2.2 Identify Input Purpose.
* Keep the submit button enabled and validate on press, so you can always reach it and hear what is missing.
* Inputs and buttons share heights of 32, 40, and 56px, above the WCAG 2.2 minimum target size of 24px.

## Related

* [Field](/docs/components/field) for labels, descriptions, and errors
* [Fieldset](/docs/components/fieldset) for grouping fields
* [Input](/docs/components/input) and [AmountInput](/docs/components/amount-input) for text and amounts
* [Button](/docs/components/button) for submit and cancel actions
* [Errors and Validation](/docs/patterns/errors-and-validation) for checking values
* [Loading](/docs/patterns/loading) for submit and load states
