---
id: "packages/active-ui/form-section"
title: "FormSection and actions"
description: "The building blocks of a page form - sections with a header, the action row, submit and cancel buttons with a pending state, and a confirmation."
url: "/packages/active-ui/form-section"
locale: "en"
revision: "8bb602771dd6921a"
---

# FormSection and actions

A form on a page is built from a few pieces: `FormSection` groups related fields in a card with a
header, `FormActions` holds the buttons of the form as a whole, `SubmitButton` and
`SaveAndContinueButton` show a pending state, `FormCancelButton` asks before it discards changes,
and `FormSuccess` confirms the result. None of them knows a form library: your application binds
them.

**Example: A settings form with sections and actions**

Simulation: A server imitated in your browser: shows the interaction, not a working server integration. Source: `examples/ui/form-section.tsx`.

```tsx
// apps/docs/examples/ui/form-section.tsx
'use client';

import {useState} from 'react';

import {SubmitButton} from '@workspace/active-ui/components/composed/buttons';
import {FormActions} from '@workspace/active-ui/components/composed/form-actions';
import {FormCancelButton} from '@workspace/active-ui/components/composed/form-cancel-button';
import {FormSection} from '@workspace/active-ui/components/composed/form-section';
import {FormSuccess} from '@workspace/active-ui/components/composed/form-success';
import {Button} from '@workspace/active-ui/components/primitives/button';
import {
  Field,
  FieldLabel,
} from '@workspace/active-ui/components/primitives/field';
import {Input} from '@workspace/active-ui/components/primitives/input';

const SAVED = {name: 'Northwind pension fund', email: 'office@example.com'};

/**
 * A settings form in two sections with the form's action row. Change a
 * field to make the form dirty: Cancel then asks before it discards. Saving
 * is simulated with a short wait; nothing is sent.
 */
export default function FormSectionExample() {
  const [saved, setSaved] = useState(SAVED);
  const [values, setValues] = useState(SAVED);
  const [isPending, setIsPending] = useState(false);
  const [message, setMessage] = useState('');
  const isDirty = values.name !== saved.name || values.email !== saved.email;

  const save = (event: React.FormEvent) => {
    event.preventDefault();
    setIsPending(true);
    setMessage('');
    window.setTimeout(() => {
      setSaved(values);
      setIsPending(false);
      setMessage('Your changes are saved.');
    }, 1200);
  };

  return (
    <form className="grid w-full max-w-xl gap-6" onSubmit={save}>
      <FormSection
        title="Organisation"
        description="How the organisation appears on statements."
      >
        <Field>
          <FieldLabel htmlFor="org-name">Name</FieldLabel>
          <Input
            id="org-name"
            value={values.name}
            onChange={(event) =>
              setValues({...values, name: event.target.value})
            }
          />
        </Field>
      </FormSection>
      <FormSection
        title="Contact"
        actions={
          <Button type="button" variant="outline" size="sm">
            Add a contact
          </Button>
        }
        footer={
          <p className="text-muted-foreground text-sm">
            Last changed: 16 May 2026
          </p>
        }
        variant="compact"
      >
        <Field>
          <FieldLabel htmlFor="org-email">Email</FieldLabel>
          <Input
            id="org-email"
            type="email"
            value={values.email}
            onChange={(event) =>
              setValues({...values, email: event.target.value})
            }
          />
        </Field>
      </FormSection>
      <FormActions>
        <FormCancelButton
          label="Cancel"
          isDirty={isDirty}
          disabled={isPending}
          onConfirm={() => {
            setValues(saved);
            setMessage('');
          }}
          dialog={{
            title: 'Unsaved changes',
            description: 'You have unsaved changes. Discard them?',
            confirmLabel: 'Discard changes',
            cancelLabel: 'Keep editing',
          }}
        />
        <SubmitButton
          isPending={isPending}
          normalLabel="Save"
          disabled={!isDirty}
        />
      </FormActions>
      <FormSuccess message={message} />
    </form>
  );
}
```

## Usage

```tsx
import {SubmitButton} from '@workspace/active-ui/components/composed/buttons';
import {FormActions} from '@workspace/active-ui/components/composed/form-actions';
import {FormCancelButton} from '@workspace/active-ui/components/composed/form-cancel-button';
import {FormSection} from '@workspace/active-ui/components/composed/form-section';

<form onSubmit={save}>
  <FormSection title="Organisation" description="How the organisation appears.">
    …fields
  </FormSection>
  <FormActions>
    <FormCancelButton
      label="Cancel"
      isDirty={isDirty}
      onConfirm={leave}
      dialog={{
        title: 'Unsaved changes',
        description: 'You have unsaved changes. Discard them?',
        confirmLabel: 'Discard changes',
        cancelLabel: 'Keep editing',
      }}
    />
    <SubmitButton isPending={isPending} normalLabel="Save" />
  </FormActions>
</form>;
```

## The pieces

- `FormSection`: a card with an `<h3>` title, a description and `actions` on the title's line (an
  add button belongs there, not below the fields), and a `footer` row aligned to the end. A
  description without a title is a muted paragraph. `variant="compact"` uses less padding. The
  page title belongs in a [page header](/packages/active-ui/page), one level up.
- `FormSectionHeader`: the same header on its own, for a section with its own frame.
- `FormActions`: the row of the form's actions, aligned to the start with a gap: cancel first, the
  primary action last. Not for dialog footers, which align to the end, nor for row or toolbar
  actions. It does not wrap.
- `SubmitButton`: `type="submit"`. While `isPending` it shows a spinner, keeps its width
  (`minWidthClass`) and its name, and is disabled; `disabled` adds a reason of your own, such as a
  form without changes.
- `SaveAndContinueButton`: a second action with its own `onClick` and the same pending state.
- `CancelButton`: a `type="button"` in the secondary variant, so it never submits.
- `FormCancelButton`: a cancel that leaves the page. A form without changes leaves at once; with
  changes, a [ConfirmActionDialog](/packages/active-ui/confirm-action-dialog) asks first, with the
  confirm in the destructive variant. For a cancel that only closes something, use `CancelButton`.
- `FormSuccess`: a confirmation in a polite live region, centred; nothing renders without a
  `message`.

## What your application provides

- The form binding, the dirty state, the pending state and what saving and leaving do.
- Every visible text, translated: there are no English defaults.
- Errors per field, with [Field](/packages/active-ui/field).

## Accessibility and keyboard

- The sections are `<section>` elements with an `<h3>` title, so the headings outline the form.
- A pending submit button keeps its accessible name behind the spinner and cannot be pressed twice.
- `FormSuccess` mounts together with its message. Some screen readers skip a live region that
  arrives with its text; where the confirmation must be heard, keep an empty `role="status"`
  region in the page and fill it, or move focus to the result.

## API

### FormSection

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | yes | The fields / content of the section. |
| `actions` | `ReactNode` | no | Optional action(s) in the header row, to the right of title and description (requires `title`). For the add button of a list section: it belongs in the header, on the same line as the title, and not in the `footer` below the grid. |
| `className` | `string` | no | Custom CSS class on the section container. |
| `description` | `string` | no | Optional description below the title. |
| `footer` | `ReactNode` | no | Optional footer row inside the section, separated by a border and aligned to the end. For section-specific actions ("Test the connection", "Reset to default") or meta information ("Last changed: …"). |
| `title` | `string` | no | Optional section title; appears as an `<h3>` at the top. |
| `variant` | `FormSectionVariant` | no | Padding variant. Default: "default" (p-6); "compact" (p-4). |

**FormSectionVariant**

```ts
type FormSectionVariant = 'default' | 'compact';
```

**FormSectionProps**

```ts
interface FormSectionProps {
  title?: string;
  description?: string;
  actions?: React.ReactNode;
  children: React.ReactNode;
  footer?: React.ReactNode;
  variant?: FormSectionVariant;
  className?: string;
}
```

### Buttons

**CancelButton**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | yes | The translated label. Required, with no English default. |
| `onClick` | `MouseEventHandler<HTMLButtonElement>` | yes | Click handler. REQUIRED — a CancelButton without a handler is inert and almost always a latent bug. For navigate-away forms with isDirty-checks use the higher-level `<FormCancelButton>` instead, which wires this internally. |
| `className` | `string` | no | Classes for the button. |
| `disabled` | `boolean` | no | Disables the button. |
| `size` | `ButtonSize` | no | The `Button` size. |
| `variant` | `ButtonVariant` | no | The `Button` variant. |

**SaveAndContinueButton**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `isPending` | `boolean` | yes | Shows a spinner and disables the button while the action runs. |
| `normalLabel` | `string` | yes | The translated label. Required, with no English default. |
| `onClick` | `() => void` | yes | Saves and moves on; the button is `type="button"`. |
| `className` | `string` | no | Classes for the button. |
| `disabled` | `boolean` | no | Disables the button for a reason of the caller's own, such as a form without changes. It combines with `isPending` and never overrides it: a pending button is always disabled, also with `disabled={false}`, so a second activation cannot start the same action again. |
| `minWidthClass` | `string` | no | A minimum width, so the button keeps its width when the spinner replaces the label. |
| `size` | `ButtonSize` | no | The `Button` size. |
| `variant` | `ButtonVariant` | no | The `Button` variant. |

**SubmitButton**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `isPending` | `boolean` | yes | Shows a spinner and disables the button while the action runs. |
| `normalLabel` | `string` | yes | The translated label. Required, with no English default: it stays the button's accessible name while pending, behind the spinner. |
| `className` | `string` | no | Classes for the button. |
| `disabled` | `boolean` | no | Disables the button for a reason of the caller's own, such as a form without changes. It combines with `isPending` and never overrides it: a pending button is always disabled, also with `disabled={false}`, so a second activation cannot start the same action again. |
| `minWidthClass` | `string` | no | A minimum width, so the button keeps its width when the spinner replaces the label. |
| `size` | `ButtonSize` | no | The `Button` size. |
| `type` | `"button" \| "reset" \| "submit"` | no | The button type. |
| `variant` | `ButtonVariant` | no | The `Button` variant. |

**CancelButtonProps**

```ts
interface CancelButtonProps {
  label: string;
  variant?: ButtonVariant;
  size?: ButtonSize;
  onClick: React.MouseEventHandler<HTMLButtonElement>;
  disabled?: boolean;
  className?: string;
  [x: string]: unknown;
}
```

**SaveAndContinueButtonProps**

```ts
interface SaveAndContinueButtonProps {
  isPending: boolean;
  normalLabel: string;
  onClick: () => void;
  variant?: ButtonVariant;
  size?: ButtonSize;
  className?: string;
  minWidthClass?: string;
  disabled?: boolean;
  [x: string]: unknown;
}
```

**SubmitButtonProps**

```ts
interface SubmitButtonProps {
  isPending: boolean;
  normalLabel: string;
  type?: 'submit' | 'button' | 'reset';
  variant?: ButtonVariant;
  size?: ButtonSize;
  className?: string;
  minWidthClass?: string;
  disabled?: boolean;
  [x: string]: unknown;
}
```

### FormCancelButton

**FormCancelButton**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `dialog` | `FormCancelButtonDialogLabels` | yes | Translated labels for the confirmation dialog. REQUIRED. |
| `isDirty` | `boolean` | yes | Whether the form has unsaved changes. When `true`, clicking the button opens the discard-changes confirmation dialog. When `false`, `onConfirm` is called immediately (no dialog shown). |
| `label` | `string` | yes | Visible label of the Cancel button itself. REQUIRED — caller passes a translated string via `t("common.button.cancel")`. |
| `onConfirm` | `() => void` | yes | Called when the user confirms they want to discard changes (or when the form was pristine). Typically `() => router.push(parentRoute)`. |
| `disabled` | `boolean` | no | Disables the Cancel button (e.g. while a save is in progress). |

**FormCancelButtonDialogLabels**

```ts
interface FormCancelButtonDialogLabels {
  title: string;
  description: string;
  confirmLabel: string;
  cancelLabel: string;
}
```

**FormCancelButtonProps**

```ts
interface FormCancelButtonProps {
  label: string;
  isDirty: boolean;
  onConfirm: () => void;
  disabled?: boolean;
  dialog: FormCancelButtonDialogLabels;
}
```

### Other parts

**FormSectionHeader**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | `string` | yes | The section title, an `<h3>`. |
| `actions` | `ReactNode` | no | Optional action(s) on the right of the header row, on the same line as the title: the add button of a list section belongs in the header, not below the grid. Below `sm` the action drops below the text. |
| `className` | `string` | no | Classes for the header. |
| `description` | `string` | no | A muted line below the title. |
| `descriptionClassName` | `string` | no | Classes for the description. |
| `titleClassName` | `string` | no | Classes for the title. |

**FormSectionHeaderProps**

```ts
interface FormSectionHeaderProps {
  title: string;
  description?: string;
  actions?: React.ReactNode;
  className?: string;
  titleClassName?: string;
  descriptionClassName?: string;
}
```

**FormActions**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | yes | The actions, in reading order. The convention is: cancel on the left, the primary action on the right.  The order is deliberately the caller's responsibility. A `cancel`/`primary` prop would introduce roles into this component, and it would then know what "cancel" and "save" mean — exactly the domain knowledge `active-ui` keeps out. |

**FormActionsProps**

```ts
interface FormActionsProps extends React.ComponentProps<'div'> {
  children: React.ReactNode;
}
```

**FormSuccess**

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `className` | `string` | no | Classes for the status region. |
| `message` | `string` | no | The translated confirmation. Without it nothing renders. |
