Modal
ImplementedCentered dialog (portal, scrim, Escape, focus trap, restore-focus, topmost-ESC stacking, 85vh panel scroll, configurable scrim, sizes, footer slot). Composes the Modal molecule for dialog chrome.
components/molecules/Modal/Modal.tsxControlled centered dialog with portal rendering, a built-in focus trap (Tab wrap + restore focus on close), topmost-only Escape for stacked modals, configurable scrim dismissal, body scroll lock with in-panel scrolling for tall content, four sizes, and an optional footer slot.
Modal
Overlay molecule (Family 6)
A controlled modal dialog. Portal-renders to document.body, locks body scroll while open (tall content scrolls inside the panel), traps Tab/Shift+Tab inside the panel and restores focus to the opener on close, closes on Escape (topmost stacked modal only) and on scrim click (disable with closeOnScrimClick), and wires role="dialog" / aria-modal / aria-labelledby (when a title is set) or aria-label automatically.
This component supports light and dark mode.
Usage
Modal is a controlled component — pair open with a piece of local state and always wire onClose. It handles the portal, body scroll lock, Escape, scrim dismissal (on by default — see closeOnScrimClick), and the role / aria-modal / aria-labelledby (title) or aria-label wiring automatically. Focus is managed by the built-in useFocusTrap hook: on open it lands on the first focusable element inside the panel (the panel itself when there is none), Tab / Shift+Tab wrap inside the panel, and focus is restored to the opener when the dialog closes. Tall content scrolls inside the panel — the body region is the scroll container under an 85vh cap while the header and footer stay put.
import { Modal, Button } from '@epasslive/design-system'
const [open, setOpen] = useState(false)
<Modal
open={open}
onClose={() => setOpen(false)}
title="Confirm refund"
size="md"
footer={
<>
<Button variant="secondary" onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="danger" onClick={refund}>Confirm refund</Button>
</>
}
>
Refunds are processed within 5–10 business days.
</Modal>Dismissal and keyboard
Two surfaces close the dialog by default: a click anywhere on the scrim and the Escape key (both call onClose). Scrim dismissal is configurable — pass closeOnScrimClick={false} for destructive or multi-step flows that must be dismissed explicitly; the scrim then renders as a non-interactive, aria-hidden overlay and only your footer actions (and Escape) close the dialog. There is no visible close button — provide your own cancel action in the footer slot. With stacked modals open, a single Escape closes only the topmost layer, so modal-over-modal flows work (one Escape per layer).
Accessible name
Always pass title or ariaLabel so the dialog has an accessible name (WCAG 4.1.2). With a title, the heading is wired to the panel via aria-labelledby; without one, ariaLabel names the dialog directly (aria-label = ariaLabel ?? title). If neither is passed the dialog renders unnamed — avoid that.
Sizing
Four width presets via size: sm (384px), md (448px, default), lg (672px), and full (90vw). The panel is capped at max-h-[85vh] and laid out as a flex column: the title and footer are pinned (shrink-0) while the body region scrolls (overflow-y-auto), so tall content scrolls inside the panel while body scroll is locked.
Dark mode
Modal swaps to ep-surface-elevated with dark slate borders and a tinted backdrop — all tokens have light and dark variants.
Props
open*booleanControls visibility.
onClose*() => voidFired on scrim click (when enabled) or Escape key — and by your own footer actions.
size'sm' | 'md' | 'lg' | 'full'mdWidth cap — sm 384px / md 448px / lg 672px / full 90vw.
titlestringOptional heading rendered at the top of the panel. When set, it is wired via aria-labelledby.
ariaLabelstringAccessible name used when title is absent (aria-label = ariaLabel ?? title). Pass one of the two so the dialog is named.
children*React.ReactNodePanel body content.
footerReact.ReactNodeFooter slot, typically a cancel + confirm action pair. Omit for a body-only dialog.
idstringmodal-<useId>Explicit id prefix for the dialog and title element (defaults to modal-<useId>).
closeOnScrimClickbooleantrueWhether clicking the scrim dismisses the dialog. Set false for destructive/flow dialogs that must be closed explicitly — the scrim becomes a non-interactive aria-hidden overlay (Escape still closes).
Props
Modal
openboolean—Whether the dialog is visible.
onClose() => void—Called when the dialog should close (ESC, scrim click, or programmatic).
childrenReact.ReactNode—Dialog body content. Rendered inside a scrollable region when tall.
sizeModalSize'md'Visual size preset — 'sm' | 'md' | 'lg' | 'full'.
titlestring—Optional header text. When present, renders an <h2> and sets aria-labelledby.
ariaLabelstring—Accessible label fallback when no title is provided (aria-label on the dialog).
footerReact.ReactNode—Action row rendered below the body (typically a cancel + confirm pair). Optional — omit for a body-only dialog.
idstringauto-generatedStable DOM id for the modal root. Auto-generated via useId when omitted.
closeOnScrimClickbooleantrueWhether clicking the scrim dismisses the dialog. Set false for destructive/flow dialogs that require explicit action.
<Modal />Accessibility
behavior
7 rulesARIA
1 ruleUsage
Import
import { Modal } from "@epasslive/design-system"Example 1
<Modal open={true} onClose={() => {}}>Content</Modal>Anti-patterns
— Do not reimplement dialog chrome — use Modal molecule
— Do not stack modals without unique ids
— Do not disable closeOnScrimClick for non-destructive dialogs