Modal (Dialog) — spec¶
Status: example artifact for
component-spec-writerskill Generated by:skills/component-spec-writer, citing Ant DesignModal, MUIDialog, shadcn-uidialog/alert-dialog/sheet
Purpose¶
A modal interrupts the user to capture a decision or focused interaction. It blocks page interaction until dismissed. Use sparingly — every modal is a small attention tax.
This spec covers dialog (centered, decision/form), alert-dialog (destructive confirm), and sheet (edge-anchored, browse/edit) as variants of one component.
When to use which variant¶
| Variant | Use when | Reference |
|---|---|---|
dialog |
Decision or focused form that interrupts the current page | shadcn dialog, MUI Dialog, Ant Modal |
alert-dialog |
Destructive confirm or important alert; requires explicit acknowledgement | shadcn alert-dialog, Ant Modal.confirm |
sheet |
Browse or edit content too long for centered modal; mobile-native pattern | shadcn sheet, MUI Drawer |
popover (not modal) |
Lightweight, non-blocking | use Popover instead |
toast (not modal) |
Transient feedback | use Toast instead |
Anatomy¶
Modal (root, portal-mounted)
├── Backdrop (click-dismiss + visual scrim)
└── Surface (the box)
├── Header
│ ├── Title
│ ├── [Description?]
│ └── [CloseButton?]
├── Body (scrollable if content overflows)
└── Footer (action buttons, right-aligned LTR)
| Part | Required | Notes |
|---|---|---|
| Backdrop | yes | semi-opaque overlay; click dismisses (configurable) |
| Title | yes | accessible name source — aria-labelledby points here |
| Description | optional | aria-describedby if present |
| CloseButton | yes (dialog), optional (alert-dialog) |
top-right ✕ |
| Body | yes | content; scrollable if tall |
| Footer | usual | right-aligned action buttons; primary on the right (LTR) |
API¶
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
— | Controlled open state. |
onOpenChange |
(open: boolean) => void |
— | Fires on open/close (backdrop click, escape, button click). |
title |
string \| ReactNode |
— | Accessible name. Required. |
description |
string \| ReactNode |
— | Subtitle, sets aria-describedby. |
size |
"sm" \| "md" \| "lg" \| "xl" \| "full" |
"md" |
Max-width tier. |
dismissible |
boolean |
true for dialog / false for alert-dialog |
If false, backdrop click and Escape do not close. |
showCloseButton |
boolean |
true |
Renders top-right ✕. |
initialFocus |
RefObject<HTMLElement> |
first focusable inside | Element to focus on open. |
restoreFocus |
boolean |
true |
Return focus to opener on close. |
position |
"center" \| "top" (dialog) / "top" \| "right" \| "bottom" \| "left" (sheet) |
"center" / "right" |
Edge for sheet variant. |
children |
ReactNode |
— | Body content. |
footer |
ReactNode |
— | Footer slot for actions. |
loading |
boolean |
false |
Disables footer actions, shows spinner. |
For composition-style API (shadcn pattern):
<Modal open={open} onOpenChange={setOpen}>
<Modal.Header>
<Modal.Title>...</Modal.Title>
<Modal.Description>...</Modal.Description>
</Modal.Header>
<Modal.Body>...</Modal.Body>
<Modal.Footer>...</Modal.Footer>
</Modal>
States¶
| State | Trigger | Behavior |
|---|---|---|
| Closed | open={false} |
Unmounted from DOM (or kept with display:none for performance). |
| Opening | transition 200ms | Backdrop fade-in 0→0.5; surface fade+scale 0.96→1 with translateY(8px)→0. |
| Open | settled | Focus inside surface, page scroll locked, page inert. |
| Closing | transition 150ms | Reverse of opening. |
Animation timing references: knowledge/motion/principles.md — modal fade-in is the canonical 200ms ease-out pattern.
Sizes¶
| Size | Max-width | Use |
|---|---|---|
sm |
400px | confirmation, simple input |
md |
560px | default; form, info |
lg |
720px | content-heavy; multi-step |
xl |
960px | data table, complex form |
full |
viewport-100% with margin | mobile, image gallery |
On mobile, all sizes collapse to width: 100% - 32px margin regardless of size prop.
Tokens consumed¶
--color-bg-elevated (surface)
--color-bg-overlay (backdrop, often rgba(0,0,0,0.5))
--color-border-default
--color-text-primary
--color-text-secondary
--space-md
--space-lg
--space-xl
--radius-lg
--shadow-3 (elevation)
--motion-default (200ms)
--easing-out
--z-modal (typically 1000+)
Accessibility — required behaviors¶
This is the most a11y-sensitive component in the system. Get the WAI-ARIA dialog pattern exactly right.
- Role:
role="dialog"(orrole="alertdialog"for destructive confirm). aria-modal="true"to indicate the rest of the page is inert.aria-labelledby={titleId}pointing at the Title element.aria-describedby={descId}if Description is present.- Focus trap: while open,
TabandShift+Tabcycle within the modal. The last element loops back to the first. - Initial focus: on open, focus moves into the modal. Default to first focusable element (or the close button if no focusable content). For destructive
alert-dialog, focus the non-destructive action by default ("Cancel"), not the destructive one. - Restore focus: on close, focus returns to the element that opened the modal.
- Escape closes (unless
dismissible={false}). - Backdrop click closes (unless
dismissible={false}). - Page scroll lock: prevent body scroll while open. iOS needs
position: fixed; top: -<scrollY>px. CSSoverflow: hiddenon body alone breaks iOS. - Page inert: set
inertattribute on the root app container while modal is open (modern browsers 2023+). Or usearia-hidden="true"on background and trap focus within modal (older approach). - Announce on open: the title is read because focus moves to / inside the labeled dialog. Don't add extra announcements — that double-announces.
- Z-index: above everything except notifications/toasts. Stacking multiple modals: prefer to refactor; if necessary, each higher modal needs +1 z-index and its own backdrop.
Code example¶
// Standard form dialog
const [open, setOpen] = useState(false);
<Modal open={open} onOpenChange={setOpen} title="Invite member">
<Modal.Body>
<Input label="Email" type="email" value={email} onChange={setEmail} />
<Input label="Role" type="text" value={role} onChange={setRole} />
</Modal.Body>
<Modal.Footer>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleInvite} loading={isPending}>Send invite</Button>
</Modal.Footer>
</Modal>
// Destructive confirm
<AlertModal
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Delete project?"
description="This permanently deletes the project and all its data. This cannot be undone."
>
<AlertModal.Footer>
<Button variant="outline" onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button intent="danger" onClick={handleDelete}>Delete project</Button>
</AlertModal.Footer>
</AlertModal>
// Sheet (mobile-friendly edit panel)
<Sheet open={editOpen} onOpenChange={setEditOpen} position="right" size="md">
<Sheet.Header>
<Sheet.Title>Edit profile</Sheet.Title>
</Sheet.Header>
<Sheet.Body>
<ProfileForm />
</Sheet.Body>
<Sheet.Footer>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>Save</Button>
</Sheet.Footer>
</Sheet>
Edge cases¶
- Long content: body scrolls; header and footer stay fixed.
- Nested modal: avoid. Refactor into wizard or pass-through navigation. If unavoidable, the inner modal's backdrop is fully opaque or significantly darker.
- Keyboard user lands on close button as first focus: jarring. Default to first interactive content;
aria-label="Close"makes the ✕ usable but not the focus target. - Mobile: keyboard appears, modal jumps: use
viewport-fit=coverand100dvhfor height. Avoid100vhon iOS Safari. - Form inside modal, Enter key submits: that's correct. Make sure the primary footer button is
type="submit"or wireonKeyDownfor Enter. - Animations + reduced motion: respect
prefers-reduced-motion. Replace fade+scale with opacity-only. - RTL:
sheet position="right"becomes left in RTL automatically with logical CSS. - Print: hide all modals (
@media print { .modal { display: none; } }).
Don't¶
- Don't put critical content only in modals — search engines can't index, browser back doesn't work intuitively.
- Don't disable the backdrop click + Escape and hide the close button. Always provide at least one dismiss path (unless it's a wizard with required steps — then a clear "Save & Close" or progress indicator).
- Don't open modals on page load without user action. Auto-open is hostile.
- Don't chain modals (modal opens another modal). Refactor.
- Don't put primary actions in the header — footer is the convention. Top-right is for close.
- Don't show the close button AND a "Cancel" button — they do the same thing. Pick one.
- Don't use modal for transient feedback (success message, info). Use a toast.
References¶
- Ant Design:
refs/ant-design/components/modal/— exhaustive:Modal,Modal.confirm,Modal.info,Modal.success,Modal.error,Modal.warning. Imperative API viaModal.confirm({...})is convenient but harder to test. The static methods are an Ant idiom worth knowing about. - MUI:
refs/mui/packages/mui-material/src/Dialog/Dialog.tsx— composition-first:<Dialog>,<DialogTitle>,<DialogContent>,<DialogActions>. Cleanest base API. - shadcn-ui:
refs/shadcn-ui/apps/v4/registry/new-york-v4/ui/dialog.tsx— Radix Dialog primitive, all a11y handled upstream.refs/shadcn-ui/apps/v4/registry/new-york-v4/ui/alert-dialog.tsx— separate primitive for confirms; difference isrole="alertdialog"and modal cannot be dismissed via Escape/backdrop unless code-driven.refs/shadcn-ui/apps/v4/registry/new-york-v4/ui/sheet.tsx— edge-anchored variant.
API choices made:
- Three variants in one spec (dialog/alert-dialog/sheet) — same focus-management contract, same a11y pattern, same composition. Fewer concepts to learn than three separate components with overlapping concerns.
- Composition over imperative: prefer <Modal>...</Modal> to Ant's Modal.confirm({...}). Imperative is fine as a wrapper for one-off confirms; composition is the foundation.
- title prop required + <Modal.Title> slot supported: prop is the 80% case (consistent a11y), slot for layouts that need custom title rendering.
Cross-reference¶
- knowledge/a11y/keyboard-and-focus.md — focus trap rules
- knowledge/motion/principles.md — modal animation timing
- WAI-ARIA Dialog Pattern
- WAI-ARIA Alert Dialog Pattern