# Modal

> Modal dialog for confirmations, forms, and focused tasks.

- Status: stable
- Group: overlay
- Page: https://www.digitaltableteur.com/design-system/components/modal
- Contract: https://www.digitaltableteur.com/contracts/v1/Modal.contract.json

## When to use

Modal is the blocking overlay for focused, dismissible tasks: confirmations, short forms, and status dialogs. Control it with `isOpen`/`onClose`; when `isOpen` turns false, focus returns to the element that was focused before it opened. While open it portals to the layer container (`document.body` by default), traps focus by marking the rest of the page `inert`, and calls `onClose` on Escape (topmost Modal only) or on a press that starts and ends on the overlay. `title` becomes the accessible name (aria-labelledby) and `description` the aria-describedby summary. `severity` swaps in a semantic header icon and escalates error/warning to `role="alertdialog"`. The footer defaults to a single OK button (hidden while `isLoading`); pass `footer` for real actions or `showFooter={false}` for fully composed bodies.

## When not to use

- Do not nest a Modal inside another Modal for a multi-step flow; swap content in place inside one overlay instead. Stacked Modals stay operable (Escape closes only the topmost), but two dialogs at once is heavier than one.
- Do not pair AlertBanner with Modal for the same status; use Modal alone for blocking alerts, the dialog role already announces.
- Do not add aria-live to the dialog root; role=dialog/alertdialog already announces, and aria-live causes double screen-reader output.
- Do not use a modal for long reading or deep multi-step forms; content that deserves scrolling deserves a page or section.

## Look-alikes

- ConfirmPopover: Do not put forms or long explanations inside; use Modal.
- Lightbox: Do not use it for non-image content. It is sized and captioned for images; use `Modal` for arbitrary content.
- MacWindowFrame: Do not use it as an actual window or dialog; Modal owns focus and dismissal semantics.
- Toast: Do not rely on Toast for compliance acknowledgements (cookie consent, legal notices); it auto-dismisses; use AlertBanner or Modal.
- Tooltip: Do not put interactive content (links, buttons) inside a tooltip; it cannot be reached by keyboard or touch; use a Modal or disclosure instead.

## Props

| Prop | Type | Default | Description | Required |
| --- | --- | --- | --- | --- |
| `severity` | `"info" \| "success" \| "warning" \| "error"` | `info` | Semantic severity level. | No |
| `isLoading` | `boolean` | `false` | Shows loading state with spinner. | No |
| `titleSize` | `"sm" \| "md" \| "lg" \| "xl" \| "xs" \| "xxs" \| "xxl" \| "XXS" \| "XS" \| "S" \| "M" \| "L" \| "XL" \| "XXL"` | `S` | Title size - supports both modern (sm/md/lg) and legacy (S/M/L) formats. | No |
| `iconSize` | `IconProps["size"]` | `lg` | Header icon size on the Icon token scale (default lg for severity dialogs) | No |
| `isOpen` | `boolean` | none | Controls visibility. | Yes |
| `title` | `string` | none | Title shown in header. | No |
| `description` | `string` | none | Supporting text, wired to aria-describedby (DialogDescription parity) | No |
| `menu` | `React.ReactNode` | none | Optional contextual menu or extra controls. | No |
| `children` | `React.ReactNode` | none | Modal content. | No |
| `footer` | `React.ReactNode` | none | Footer content, e.g. actions. | No |
| `onClose` | `() => void` | none | Close callback. | No |
| `icon` | `React.ReactNode` | none | Optional icon to display in the header. | No |
| `className` | `string` | none | Optional className for additional styling on the panel. | No |
| `showFooter` | `boolean` | `true` | When false, omit the footer region (e.g. composable dialog bodies) | No |
| `panelRef` | `React.Ref<HTMLDivElement>` | none | Ref on the dialog panel for animation hooks. | No |
| `animation` | `"none" \| "scale" \| "slide" \| "fade"` | `scale` | Panel motion: it enters on open and leaves on close, the exit 20% shorter. Reduced motion drops it. Defaults to `scale` (from 0.95). | No |
| `showCloseIcon` | `boolean` | `false` | Show close icon button in header. | No |
| `closeIconName` | `string` | `x` | Custom close icon name (defaults to "x") | No |
| `closeButtonLabel` | `string` | Close dialog | Custom close button aria-label. | No |

## Variants

- Available severities: `info`, `success`, `warning`, `error` (default `info`).
- Allowed `titleSize` values: `sm`, `md`, `lg`, `xl`, `xs`, `xxs`, `xxl`, `XXS`, `XS`, `S`, `M`, `L`, `XL`, `XXL` (default `S`).
- Allowed `animation` values: `none`, `scale`, `slide`, `fade` (default `scale`).

## Invalid combinations

The contract records no invalid combinations.

## Keyboard

| Key | Behaviour |
| --- | --- |
| Escape | Closes or dismisses the open layer. (standard) |
| Tab | Moves focus to the control and on to the next focusable element. (standard) |

Rows marked (standard) name a key the contract lists without a description; the behaviour shown is the usual one for that key.

## Rules

Every item under When not to use is a MUST NOT. MUST and SHOULD follow RFC 2119.

| Level | Rule | How it is checked |
| --- | --- | --- |
| MUST | ARIA: Role=dialog. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Focus trap while open. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Accessible title. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Escape closes only the topmost open Modal, and is ignored when a nested layer already handled it (defaultPrevented), when a layer opened above has made this panel inert, or when the press came from inside another aria-modal layer. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Backdrop close needs the whole press on the backdrop: a press that starts inside the panel (a text selection) and is released on the backdrop does not close. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | Required stories produce no axe violations at the configured severity. | Automated by `npm run test:stories:matrix:ci`. WCAG 2.5.8. |
| MUST | The accessibility tree (role, name, state) matches the committed snapshot for every required story. | Automated by `npm run test:stories:matrix:ci`. WCAG 1.3.1, 4.1.2. |
| MUST | The component remains legible and operable under a real browser forced-colors: active pass. | Automated by `npm run test:stories:hc`. |
| MUST | Every documented keyboard interaction works as specified. | Not yet verified: no evidence record. WCAG 2.1.1, 2.1.2. |
| MUST | A human completed an end-to-end accessibility review of this component. | Manual review, last on 2026-11-04. |
| SHOULD | Keep the Modal mounted and toggle isOpen; it renders nothing while closed, stores the previously focused element on open and restores it on close. | Not stated in the contract. |
| SHOULD | Always pass title; it is the dialog's accessible name and the only way to get a header. Untitled dialogs fall back to a generic aria-label="Dialog" and render no close button. | Not stated in the contract. |
| SHOULD | Use description for the one-line summary screen readers announce on open (wired to aria-describedby), then put the rest in children. | Not stated in the contract. |
| SHOULD | Use severity for status dialogs: it derives the header icon and promotes error/warning to role=alertdialog for assertive announcement. | Not stated in the contract. |
| SHOULD | Give destructive confirmations severity=error and an explicit footer (secondary Cancel + error-toned action) so the dialog chrome and action communicate the same risk; reserve the default OK-only footer for acknowledgements. | Not stated in the contract. |
| SHOULD NOT | Do not open one modal from another for a multi-step flow; swap the content of the open modal in place instead. If a second layer is unavoidable, Escape closes only the topmost. | Not stated in the contract. |
| SHOULD NOT | Do not add aria-live near or on the dialog; the role already announces and you get a double reading. | Not stated in the contract. |

## Install and import

- Install: `npm install @digitaltableteur/react` (version 0.9.1, restricted access on npm).
- Import: `import { Modal } from "@digitaltableteur/react";`
- Entry point: `@digitaltableteur/react/patterns` exports it too.
- Styles: import `@digitaltableteur/react/style.css` once at the app boundary.
- Also available as the custom element `<dt-modal>`; see the custom elements manifest.

## Links

- Machine-readable contract (Design System Contract 1.0): https://www.digitaltableteur.com/contracts/v1/Modal.contract.json
- Markdown version of this page: https://www.digitaltableteur.com/design-system/components/modal.md
- Component index as JSON: https://www.digitaltableteur.com/design-system/components.json
- All components: https://www.digitaltableteur.com/design-system/components
- Source: The implementation lives in a private repository. This page, its Markdown twin and the contract are generated from it at build time and are the public source of truth.
