Modal
Modal dialog for confirmations, forms, and focused tasks.
Status: stable. Group: overlay. Markdown version: /design-system/components/modal.md
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
Modalfor 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(defaultinfo). - Allowed
titleSizevalues:sm,md,lg,xl,xs,xxs,xxl,XXS,XS,S,M,L,XL,XXL(defaultS). - Allowed
animationvalues:none,scale,slide,fade(defaultscale).
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/patternsexports it too. - Styles: import
@digitaltableteur/react/style.cssonce 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)
- Markdown version of this page
- Component index as JSON
- All 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.