Passer au contenu principal

Modal

Implemented

Centered 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.

Categorymolecules
Sourcecomponents/molecules/Modal/Modal.tsx
AI HintUse for centered overlay dialogs. Composes into organisms for domain-specific flows (TicketTransferModal). For side-anchored overlays, use Drawer. For command palettes, use CommandMenu.

Controlled 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.

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

Prop
Type
Default
Description
open*
boolean
—

Controls visibility.

onClose*
() => void
—

Fired on scrim click (when enabled) or Escape key — and by your own footer actions.

size
'sm' | 'md' | 'lg' | 'full'
md

Width cap — sm 384px / md 448px / lg 672px / full 90vw.

title
string
—

Optional heading rendered at the top of the panel. When set, it is wired via aria-labelledby.

ariaLabel
string
—

Accessible name used when title is absent (aria-label = ariaLabel ?? title). Pass one of the two so the dialog is named.

children*
React.ReactNode
—

Panel body content.

footer
React.ReactNode
—

Footer slot, typically a cancel + confirm action pair. Omit for a body-only dialog.

id
string
modal-<useId>

Explicit id prefix for the dialog and title element (defaults to modal-<useId>).

closeOnScrimClick
boolean
true

Whether 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

Preview

Modal

Prop
Type
Required
Default
Description
open
boolean
Required
—

Whether the dialog is visible.

onClose
() => void
Required
—

Called when the dialog should close (ESC, scrim click, or programmatic).

children
React.ReactNode
Required
—

Dialog body content. Rendered inside a scrollable region when tall.

size
ModalSize
Optional
'md'

Visual size preset — 'sm' | 'md' | 'lg' | 'full'.

title
string
Optional
—

Optional header text. When present, renders an <h2> and sets aria-labelledby.

ariaLabel
string
Optional
—

Accessible label fallback when no title is provided (aria-label on the dialog).

footer
React.ReactNode
Optional
—

Action row rendered below the body (typically a cancel + confirm pair). Optional — omit for a body-only dialog.

id
string
Optional
auto-generated

Stable DOM id for the modal root. Auto-generated via useId when omitted.

closeOnScrimClick
boolean
Optional
true

Whether clicking the scrim dismisses the dialog. Set false for destructive/flow dialogs that require explicit action.

Quick use:<Modal />

Accessibility

ARIA roledialog

behavior

7 rules

WCAG 2.1.2

Focus trap via useFocusTrap — Tab/Shift+Tab wrap inside the panel

WCAG 2.4.3

Restore focus to the previously focused element on close

WAI-ARIA dialog pattern

ESC closes topmost modal only (module-level openModals stack)

layout invariant

Panel max-h-[85vh] with flex column — header/footer stay reachable, body scrolls

UX invariant

Body scroll locked (overflow:hidden) while open, restored on close

React portal pattern

Portal to document.body via createPortal

UX pattern

Scrim click configurable via closeOnScrimClick (default true)

ARIA

1 rule

WAI-ARIA 1.2

role=dialog, aria-modal=true, aria-labelledby when title present, aria-label fallback

Usage

Import

tsxSnippet
import { Modal } from "@epasslive/design-system"

Example 1

tsxSnippet
<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

Design System v4.1.0 | ePassLive