# Modal (/docs/modal)





The `Modal` is a centered overlay built on Ark UI's `Dialog` state machine. Ark owns focus-trapping, body scroll-lock, RTL, the `Esc` / click-outside behaviour and the `data-state` attribute that drives the enter/exit animations; the kit adds a flat props API plus styled, composable parts.

```tsx
import { Modal } from '@42-central/ui-react/modal';

<Modal open={open} onOpenChange={setOpen} title="Welcome">
  <Text>Anything you like in the body.</Text>
</Modal>
```

To spawn modals imperatively — without `useState` at every call site — use the [overlays manager](/docs/overlays).

## Basic usage [#basic-usage]

`open` / `onOpenChange` make the modal a controlled component. `onOpenChange` is flattened to `(open: boolean) => void`.

<Preview align="center" code="&#x22;const [open, setOpen] = useState(false);\n\n<Button onClick={() => setOpen(true)}>Open modal</Button>\n<Modal open={open} onOpenChange={setOpen} title=\&#x22;Welcome aboard\&#x22;>\n  <Text>Focus is trapped and the page is scroll-locked while open.</Text>\n</Modal>&#x22;">
  <ModalExample label="Open modal" title="Welcome aboard" />
</Preview>

## Sizes [#sizes]

`size` maps to the kit's width tokens (`xs`–`xl`), plus `full` for a near-fullscreen sheet.

<Preview align="center" code="&#x22;<Modal size=\&#x22;sm\&#x22; … />\n<Modal size=\&#x22;md\&#x22; … />  // default\n<Modal size=\&#x22;lg\&#x22; … />\n<Modal size=\&#x22;xl\&#x22; … />&#x22;">
  <div className="flex flex-wrap items-center gap-2">
    <ModalExample label="xs" size="xs" title="Extra small" />

    <ModalExample label="sm" size="sm" title="Small" />

    <ModalExample label="md" size="md" title="Medium" />

    <ModalExample label="lg" size="lg" title="Large" />

    <ModalExample label="xl" size="xl" title="Extra large" />
  </div>
</Preview>

## Centered [#centered]

By default the modal anchors near the top of the viewport (so tall content grows downward). Set `centered` to vertically center it.

<Preview align="center" code="`<Modal centered title=&#x22;Centered&#x22; … />`">
  <div className="flex flex-wrap items-center gap-2">
    <ModalExample position="top" label="Top-anchored" title="Top-anchored" />

    <ModalExample label="Centered" title="Centered" />

    <ModalExample position="bottom" label="Bottom-anchored" title="Bottom-anchored" />
  </div>
</Preview>

## Description & close button [#description--close-button]

`title` and `description` render the header; `withCloseButton` (default `true`) adds a dogfooded `ActionIcon` close button.

<Preview align="center" code="&#x22;<Modal\n  title=\&#x22;Delete file\&#x22;\n  description=\&#x22;This action cannot be undone.\&#x22;\n  withCloseButton={false}\n/>&#x22;">
  <div className="flex flex-wrap items-center gap-2">
    <ModalExample label="With description" title="Delete file" description="This action cannot be undone." />

    <ModalExample label="No close button" title="No close button" withCloseButton="false" />
  </div>
</Preview>

## Composable parts [#composable-parts]

The high-level `<Modal>` covers the common case. For full control over structure, compose the parts directly — they carry the same styling and Ark wiring:

```tsx
<Modal.Root open={open} onOpenChange={({ open }) => setOpen(open)}>
  <Modal.Trigger asChild>
    <Button>Open</Button>
  </Modal.Trigger>
  <Modal.Backdrop />
  <Modal.Positioner centered>
    <Modal.Content size="md">
      <Modal.Header>
        <Modal.Title>Custom layout</Modal.Title>
        <Modal.CloseTrigger />
      </Modal.Header>
      <Modal.Body>…</Modal.Body>
      <Modal.Footer>
        <Button>Cancel</Button>
        <Button>Save</Button>
      </Modal.Footer>
    </Modal.Content>
  </Modal.Positioner>
</Modal.Root>
```

`Modal.Root`, `Modal.Trigger`, and `Modal.Context` are raw Ark re-exports with no DOM node (or styling) of their own, so none take a `testId`. `Modal.Backdrop`, `Modal.Positioner`, `Modal.Content`, `Modal.Title`, `Modal.Description`, `Modal.Header`, `Modal.Body`, and `Modal.Footer` each take their own `testId`, landing on their own rendered element. `Modal.CloseTrigger`'s `testId` routes to the actual rendered close button — an inner `ActionIcon`, since `CloseTrigger` itself is `asChild` over it. See [Testing](/docs/testing).

## API [#api]

<TypeTable
  type="{
  open: {
    type: 'boolean',
    description: 'Controlled open state.',
  },
  onOpenChange: {
    type: '(open: boolean) => void',
    description: &#x22;Fires with the next open state. Flattened from Ark's { open }.&#x22;,
  },
  title: {
    type: 'ReactNode',
    description: 'Header title. Renders the header when set.',
  },
  description: {
    type: 'ReactNode',
    description: 'Optional subtitle under the title.',
  },
  size: {
    type: &#x22;'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full'&#x22;,
    default: &#x22;'md'&#x22;,
    description: 'Maps to the kit width tokens.',
  },
  radius: {
    type: &#x22;'none' | 'sm' | 'md' | 'lg' | 'xl'&#x22;,
    default: &#x22;'lg'&#x22;,
    description: 'Corner radius of the content surface.',
  },
  centered: {
    type: 'boolean',
    default: 'false',
    description: 'Vertically center the modal.',
  },
  withCloseButton: {
    type: 'boolean',
    default: 'true',
    description: 'Render the default header close button.',
  },
  translations: {
    type: '{ closeTriggerLabel?: string }',
    description: &#x22;Accessible name of the default close button (default \&#x22;Close\&#x22;). The compound CloseTrigger part takes a plain aria-label instead.&#x22;,
  },
  padding: {
    type: &#x22;'xs' | 'sm' | 'md' | 'lg' | 'xl'&#x22;,
    default: &#x22;'md'&#x22;,
    description: 'Inner padding token; sets --modal-px.',
  },
  closeOnEscape: {
    type: 'boolean',
    default: 'true',
    description: 'Close when Escape is pressed.',
  },
  closeOnInteractOutside: {
    type: 'boolean',
    default: 'true',
    description: 'Close when the backdrop / outside is clicked.',
  },
  role: {
    type: &#x22;'dialog' | 'alertdialog'&#x22;,
    default: &#x22;'dialog'&#x22;,
    description: &#x22;Use 'alertdialog' for confirm flows.&#x22;,
  },
  classNames: {
    type: 'Partial<Record<&#x22;backdrop&#x22; | &#x22;positioner&#x22; | &#x22;content&#x22; | &#x22;header&#x22; | &#x22;title&#x22; | &#x22;description&#x22; | &#x22;body&#x22; | &#x22;closeButton&#x22;, string>>',
    description: 'Per-part class overrides.',
  },
  testId: {
    type: 'string',
    description: 'Renders as `data-testid` on `Modal.Content` — the actual dialog surface — since that\'s what the top-level `<Modal>` shorthand renders as its root. See [Testing](/docs/testing).',
  },
}"
/>

All other props (`modal`, `trapFocus`, `preventScroll`, `id`, `lazyMount`, …) forward to Ark's `Dialog.Root`.

## Accessibility [#accessibility]

* Ark traps focus inside the content while open and restores it to the trigger on close.
* The body is scroll-locked (`preventScroll`) and content below is hidden from assistive tech (`modal`).
* `title` wires `aria-labelledby` and `description` wires `aria-describedby` automatically. When you omit a `title`, pass an `aria-label`.
* Animations collapse to a 1ms opacity tween under `prefers-reduced-motion`.
