# Form Card

A card for a settings form, with one row for each setting and a footer that saves.

```tsx
import * as React from 'react'
import { FormActions, FormCard, FormRow, Input, toast } from 'ferry-ui'

const INITIAL = { name: 'Billing portal', email: 'billing@example.com' }

export default function FormCardHero() {
  const [saved, setSaved] = React.useState(INITIAL)
  const [draft, setDraft] = React.useState(INITIAL)
  const [saving, setSaving] = React.useState(false)
  const dirty = draft.name !== saved.name || draft.email !== saved.email

  function save(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setSaving(true)
    // Stands for a request to the server.
    window.setTimeout(() => {
      setSaved(draft)
      setSaving(false)
      toast.success('Settings saved')
    }, 1000)
  }

  return (
    <FormCard
      className="mx-auto w-full max-w-2xl"
      title="Project"
      description="These settings apply to each member of the project."
      onSubmit={save}
      footer={<FormActions dirty={dirty} saving={saving} onReset={() => setDraft(saved)} />}
    >
      <FormRow label="Name" description="The name shows in the list of projects." htmlFor="project-name">
        <Input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} />
      </FormRow>
      <FormRow label="Billing email" description="The app sends the invoices to this address." htmlFor="project-email">
        <Input type="email" value={draft.email} onChange={(event) => setDraft({ ...draft, email: event.target.value })} />
      </FormRow>
    </FormCard>
  )
}
```

## Usage guidelines

- **One card for each topic.** Use `FormCard` for a settings form. Each `FormRow` holds one setting.
- **A short form is not a card.** In a dialog or a sign-in form, use [Field](/docs/components/field).
- **You do the validation.** The form has `noValidate`. Check the values in `onSubmit`.
- **Destructive actions go last.** Group them in one `tone="destructive"` card at the bottom of the page.
- **A long editor has its own footer.** Use [Save Bar](/docs/components/save-bar) there.

## Anatomy

Import the parts and put them together.

```tsx title="Anatomy"

<FormCard title="" description="" footer={<FormActions dirty={false} />}>
  <FormRow label="" description="" htmlFor="">
    {/* one control */}
  </FormRow>
</FormCard>

<FormCard asDiv>
  <ActionRow title="" description="" action={<Button />} />
</FormCard>
```

| Part | Role |
| --- | --- |
| `FormCard` | The card. It holds the header, the rows and the footer. |
| `FormRow` | One setting. The label is on the left and the control is on the right. |
| `FormActions` | The content of the footer: a status, Cancel and Save. |
| `ActionRow` | One action. The text is on the left and one button is on the right. |

## Examples

### Validation error

Pass the message to `error` on the row. After a submit that fails, set `invalid` on `FormActions`. Save stays enabled. The user corrects the field and submits again.

```tsx
import * as React from 'react'
import { FormActions, FormCard, FormRow, Input, toast } from 'ferry-ui'

const SLUG = /^[a-z0-9-]+$/

export default function FormCardValidation() {
  const [saved, setSaved] = React.useState('billing-portal')
  const [slug, setSlug] = React.useState('Billing portal')
  const [submitted, setSubmitted] = React.useState(false)
  const error = submitted && !SLUG.test(slug) ? 'Use lowercase letters, digits and dashes only.' : undefined

  function save(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    // The form has `noValidate`: the code does the validation on submit.
    setSubmitted(true)
    if (!SLUG.test(slug)) return
    setSaved(slug)
    setSubmitted(false)
    toast.success('Settings saved')
  }

  return (
    <FormCard
      className="mx-auto w-full max-w-2xl"
      title="Workspace"
      onSubmit={save}
      footer={<FormActions dirty={slug !== saved} invalid={error !== undefined} />}
    >
      <FormRow label="Slug" description="The slug is a part of the workspace URL." htmlFor="workspace-slug" error={error}>
        <Input mono value={slug} onChange={(event) => setSlug(event.target.value)} />
      </FormRow>
    </FormCard>
  )
}
```

### Composite control and group

With `htmlFor`, the row gives the `id` and the ARIA attributes to one control. For a [Select](/docs/components/select), pass a function and spread `control` on the trigger.

For a group, leave out `htmlFor`. The `control` object then has `aria-labelledby`.

```tsx
import {
  FormCard,
  FormRow,
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
  ToggleGroup,
  ToggleGroupItem,
} from 'ferry-ui'

export default function FormCardComposite() {
  return (
    <FormCard asDiv className="mx-auto w-full max-w-2xl" title="Invoices" description="The defaults for new invoices.">
      <FormRow label="Currency" description="The currency of each new invoice." htmlFor="invoice-currency">
        {/* With `htmlFor`: `control` has the id and the ids of the description and of the error. */}
        {(control) => (
          <Select defaultValue="eur">
            <SelectTrigger {...control} className="w-full">
              <SelectValue />
            </SelectTrigger>
            <SelectContent position="popper">
              <SelectItem value="eur">Euro (EUR)</SelectItem>
              <SelectItem value="usd">US dollar (USD)</SelectItem>
              <SelectItem value="gbp">Pound sterling (GBP)</SelectItem>
            </SelectContent>
          </Select>
        )}
      </FormRow>
      <FormRow label="Payment terms" description="The time that a customer has to pay an invoice.">
        {/* No `htmlFor`: `control` also has `aria-labelledby`, which names the group. */}
        {(control) => (
          <ToggleGroup {...control} type="single" variant="outline" defaultValue="30">
            <ToggleGroupItem value="14">14 days</ToggleGroupItem>
            <ToggleGroupItem value="30">30 days</ToggleGroupItem>
            <ToggleGroupItem value="60">60 days</ToggleGroupItem>
          </ToggleGroup>
        )}
      </FormRow>
    </FormCard>
  )
}
```

### Read-only card

With `asDiv`, the card is a `<div>` and not a form. Use it for values that the user copies and for settings that save on a change. `headerActions` puts a badge or a small button at the right of the header.

```tsx
import { CopyField, FormCard, FormRow, SecretField, StatusBadge } from 'ferry-ui'

export default function FormCardReadOnly() {
  return (
    <FormCard
      asDiv
      className="mx-auto w-full max-w-2xl"
      title="API access"
      description="Use these values to call the API from your server."
      headerActions={<StatusBadge tone="success" label="Active" size="sm" />}
    >
      <FormRow label="Project ID" htmlFor="api-project-id">
        <CopyField value="prj_7Hq2kLx9Vd3mN4" what="project ID" />
      </FormRow>
      <FormRow label="Secret key" description="Keep this key on the server." htmlFor="api-secret-key">
        <SecretField value="sk_demo_4f9a2c7e1b8d3f6a" what="secret key" />
      </FormRow>
      <FormRow label="Plan">
        <span className="text-sm text-foreground">Pro</span>
      </FormRow>
    </FormCard>
  )
}
```

### Row layout

Set `layout` to `vertical` to put a wide control below its label. `controlClassName` sets the width of the control column.

```tsx
import { FormCard, FormRow, Input, Textarea } from 'ferry-ui'

export default function FormCardLayout() {
  return (
    <FormCard asDiv className="mx-auto w-full max-w-2xl" title="Invoices">
      <FormRow
        label="Payment terms"
        description="The number of days that a customer has to pay."
        htmlFor="terms-days"
        controlClassName="max-w-24"
      >
        <Input type="number" defaultValue="30" className="tabular" />
      </FormRow>
      <FormRow
        layout="vertical"
        label="Footer note"
        description="The text at the bottom of each invoice."
        htmlFor="invoice-note"
      >
        <Textarea defaultValue="Thank you for your order. Send your questions to billing@example.com." />
      </FormRow>
    </FormCard>
  )
}
```

### Footer

`FormActions` enables Save only while `dirty` is true. Set `saving` while the request is in progress. Pass `onReset` to show Cancel.

`hint` shows a note while the form has no change. `saveLabel` replaces the text of Save.

In a card with `asDiv`, pass `onSave`. In a form, Save submits the form.

```tsx
import * as React from 'react'
import { FormActions, FormCard, FormRow, Input, toast } from 'ferry-ui'

export default function FormCardFooter() {
  const [saved, setSaved] = React.useState('500')
  const [limit, setLimit] = React.useState('500')

  return (
    // The card is a `<div>`: Save calls `onSave`, it does not submit a form.
    <FormCard
      asDiv
      className="mx-auto w-full max-w-2xl"
      title="Usage limits"
      footer={
        <FormActions
          dirty={limit !== saved}
          hint="The limit applies to new requests only"
          saveLabel="Apply limit"
          onReset={() => setLimit(saved)}
          onSave={() => {
            setSaved(limit)
            toast.success('Limit applied')
          }}
        />
      }
    >
      <FormRow label="Requests per minute" htmlFor="limit-requests" controlClassName="max-w-32">
        <Input type="number" value={limit} onChange={(event) => setLimit(event.target.value)} className="tabular" />
      </FormRow>
    </FormCard>
  )
}
```

### Action rows

An `ActionRow` has no value to save. Its button runs the action on a click.

```tsx
import { ActionRow, Button, FormCard, FormRow, Switch, toast } from 'ferry-ui'
import { Download, LogOut } from 'lucide-react'

export default function FormCardActionRows() {
  return (
    <FormCard asDiv className="mx-auto w-full max-w-2xl" title="Security" description="The sessions and the data of your account.">
      <FormRow
        label="Two-factor authentication"
        description="The app asks for a code when you sign in."
        htmlFor="security-two-factor"
      >
        <Switch defaultChecked />
      </FormRow>
      <ActionRow
        title="Sign out everywhere"
        description="This stops each session but this one."
        action={
          <Button icon={<LogOut />} onClick={() => toast.success('Other sessions signed out')}>
            Sign out other sessions
          </Button>
        }
      />
      <ActionRow
        title="Export your data"
        description="You get a file with your projects and your invoices."
        action={
          <Button icon={<Download />} onClick={() => toast.success('Export requested')}>
            Request export
          </Button>
        }
      />
    </FormCard>
  )
}
```

### Danger zone

With `tone="destructive"`, the card has a red border and its action rows have a red tint. Open a [Confirm Dialog](/docs/components/confirm-dialog) from each destructive button.

```tsx
import { ActionRow, Button, ConfirmDialog, FormCard, toast } from 'ferry-ui'

export default function FormCardDangerZone() {
  return (
    <FormCard
      asDiv
      tone="destructive"
      className="mx-auto w-full max-w-2xl"
      title="Danger zone"
      description="Actions that freeze or remove this project."
    >
      <ActionRow
        title="Archive project"
        description="The project becomes read-only. You can restore it later."
        action={<Button onClick={() => toast.success('Project archived')}>Archive project</Button>}
      />
      <ActionRow
        title="Delete project"
        description="This deletes the project, its invoices and its API keys. You cannot undo this."
        action={
          <ConfirmDialog
            trigger={<Button variant="destructive">Delete project</Button>}
            title="Delete project “Billing portal”?"
            description="The invoices and the API keys of this project go with it. You cannot undo this."
            confirmLabel="Delete project"
            onConfirm={() => {
              toast.success('Project deleted')
            }}
          />
        }
      />
    </FormCard>
  )
}
```

## Accessibility

- With `htmlFor`, the row connects the label, the description and the error to the control. Do not set `id`, `aria-describedby` or `aria-invalid` on the control.
- The error of a row has `role="alert"`.

## API reference

### FormCard

`FormCard` also accepts each attribute of the `<form>` element. With `asDiv`, these attributes go to the card.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` | `ReactNode` |  | Optional header title (14px medium `<h3>`). It also names the form (or, with `asDiv`, the card as a `role="group"`) for assistive tech through `aria-labelledby`, unless you pass `aria-label` / `aria-labelledby` yourself. |
| `description` | `ReactNode` |  | Secondary line under the title (13px, lighter). |
| `headerActions` | `ReactNode` |  | Compact controls at the right of the header row (a docs link, a small button, a badge). |
| `footer` | `ReactNode` |  | Footer row, right-aligned on a tinted strip — usually `FormActions`. |
| `asDiv` | `boolean` | `false` | Render a `<div>` instead of a `<form>`: for read-only cards (values, copy fields, toggles saved instantly) and cards of `ActionRow`s. The remaining props (`id`, `ref`, `style`, `data-*`, `aria-*`, event handlers) then go to the card element, so `ref` receives a `<div>`. Form-only props (`onSubmit`, `action`, `noValidate`…) have no effect on a `<div>`: do not pass them. |
| `tone` | `"default" \| "destructive" \| "neutral"` | `neutral` | `default` (neutral) or `destructive`: red-tinted border for the "Danger zone" card. `ActionRow`s inside inherit the tone (faint red wash) unless they set their own `tone`. See `FormCardTone`. |

### FormRow

`FormRow` also accepts each attribute of the `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `ReactNode` |  | Field name (14px medium). Rendered as a `<label>` tied to the control when `htmlFor` is set, else as plain text. With `htmlFor` it gets the id `<htmlFor>-label`. |
| `children` (required) | `ReactNode \| ((control: FieldControlProps, meta: FieldRenderMeta) => ReactNode)` |  | The control(s): Input, Select, Switch, Textarea, CopyField, custom widgets. - A single control element (a component such as `Input`, or a native `<input>`, `<select>`, `<textarea>`, `<button>`) with `htmlFor` set: the row injects `id` (unless the control has its own), `aria-describedby` (the description and error ids, merged with any the control already has) and, while `error` is set, `aria-invalid`. - A render function `(control, { labelId }) => node` for composite controls: spread `control` onto the focusable element (`<SelectTrigger {...control}>`, the input of an input + button row). Without `htmlFor` the ids are generated and `control` also carries `aria-labelledby`: use that for groups (`RadioGroup`, `ToggleGroup`, `RadioCardGroup`). - Anything else (several nodes, a wrapper `<div>`, plain text) is rendered as is: wire the control inside by hand, or use the render function. |
| `description` | `ReactNode` |  | Help text under the label (13px, lighter). With `htmlFor`, it gets the id `<htmlFor>-description` and the control references it through `aria-describedby`. |
| `htmlFor` | `string` |  | Id of the control this row labels (becomes the label's `for`). Set it whenever the control is focusable: the row then gives the control its `id`, `aria-describedby` and `aria-invalid` (see `children`). Omit it for read-only content and for groups with no single focus target. |
| `error` | `ReactNode` |  | Validation error under the control (13px destructive, `role="alert"`). With `htmlFor`, it gets the id `<htmlFor>-error`, is added to the control's `aria-describedby`, and the control gets `aria-invalid`. |
| `layout` | `"horizontal" \| "vertical"` | `horizontal` | `horizontal` (default): label column left, control column right from the `md` breakpoint (stacked below). `vertical`: always stacked — for wide controls (tables, code, editors). |
| `controlClassName` | `string` |  | Classes for the control column (e.g. `max-w-xs` to narrow a short field). |

### FormActions

`FormActions` accepts only the props of this table.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `dirty` (required) | `boolean` |  | The form differs from its saved state: enables Save and Cancel and shows the "unsaved changes" status. |
| `saving` | `boolean` | `false` | A save is in flight: spinner on Save, Cancel disabled. |
| `invalid` | `boolean` | `false` | The last submit failed validation: appends `invalidMessage` to the unsaved status. Save stays enabled, so the user can fix the fields and submit again (validation runs on submit). `SaveBar` differs on purpose: its `invalid` reflects live validation and disables Save. |
| `onReset` | `(() => void)` |  | Shows a Cancel button that restores the saved values. Omit it to hide Cancel. |
| `onSave` | `(() => void)` |  | Called by the Save button instead of submitting the form. Only needed when the card is not a `<form>` (`asDiv`); by default Save is a `type="submit"` button and the form's `onSubmit` runs. |
| `saveLabel` | `ReactNode` | `Save changes` | Save button text (default "Save changes"). Name the outcome when it helps ("Apply limits", "Update plan"). |
| `cancelLabel` | `ReactNode` | `Cancel` | Cancel button text (default "Cancel"). |
| `unsavedLabel` | `ReactNode` | `Unsaved changes` | Status shown on the left while `dirty` (default "Unsaved changes", with an amber dot). Pass `null` to hide it. |
| `invalidMessage` | `ReactNode` | `fix the highlighted fields` | Appended to the unsaved status when `invalid` (default "fix the highlighted fields"). |
| `hint` | `ReactNode` |  | Neutral note shown on the left while pristine, from `sm` up ("Changes apply to new invoices only"). |
| `children` | `ReactNode` |  | Extra content on the left, after the status (e.g. an "Also notify the team" checkbox). |

### ActionRow

`ActionRow` also accepts each attribute of the `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` (required) | `ReactNode` |  | What the action does, as a short name (14px medium): "Delete project", "Transfer ownership". |
| `action` (required) | `ReactNode` |  | The control on the right, usually one `Button` (`variant="destructive"` for destructive actions) that runs the action or opens a `ConfirmDialog`. Kept at its natural width; below `sm` it wraps under the text. |
| `description` | `ReactNode` |  | Consequences, under the title (13px, lighter): what changes, what is kept, whether it can be undone. |
| `tone` | `"default" \| "destructive" \| "neutral"` |  | `destructive` adds a faint red wash to the row. Defaults to the tone of the enclosing `FormCard` (so every row of a `tone="destructive"` card is tinted); set it to override per row. |
