Skip to content

Modal (Dialog) — spec

Status: example artifact for component-spec-writer skill Generated by: skills/component-spec-writer, citing Ant Design Modal, MUI Dialog, shadcn-ui dialog/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" (or role="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, Tab and Shift+Tab cycle 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. CSS overflow: hidden on body alone breaks iOS.
  • Page inert: set inert attribute on the root app container while modal is open (modern browsers 2023+). Or use aria-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=cover and 100dvh for height. Avoid 100vh on iOS Safari.
  • Form inside modal, Enter key submits: that's correct. Make sure the primary footer button is type="submit" or wire onKeyDown for 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

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