Design.dev design.dev
Overlays · Component Template

Modal & Dialog

An accessible modal dialog with a real focus trap, Escape to close, scroll lock, and focus restoration.

View prompt

Live demo

Vanilla JS · Focus Trap · ARIA Dialog

Two dialogs, one component. Open either, then press Tab to feel the focus trap, Esc to close, or click the dimmed backdrop. Focus returns to the button you came from.

The Prompt 704 words

Build an accessible modal dialog component using vanilla HTML, CSS, and JavaScript. No libraries, no frameworks — zero dependencies. ONE class must drive any number of dialogs on the page.

VISUAL DESIGN
- Overlay: position fixed, inset 0, flexbox-centered, background rgba(0,0,0,0.6) with backdrop-blur, z-index above all page content
- Dialog: max-width ~420px, max-height calc(100vh - 48px) with internal scroll, background rgba(22,22,26,0.98), 1px border at rgba(255,255,255,0.1), border-radius 16px, heavy box-shadow (0 24px 70px rgba(0,0,0,0.55)), 24px padding
- Header: a title and a 32px square close button (×) on the right; close button turns red on hover
- Footer: right-aligned action buttons; a primary button in cyan (#00c4ff) with dark text and a secondary/ghost button
- Focus rings: every interactive element shows a visible cyan focus ring (:focus-visible) — never remove outlines without a replacement

ENTER / EXIT ANIMATION
- Overlay fades in over 180ms; dialog scales from 0.97 + translateY(8px) to 1.0 over 200ms cubic-bezier(0.16, 1, 0.3, 1)
- prefers-reduced-motion: skip all animations and transitions

ARIA SEMANTICS (WAI-ARIA dialog pattern)
- Dialog element: role="dialog", aria-modal="true", aria-labelledby pointing at the title id, and aria-describedby pointing at the body text id when present
- The trigger button opens the dialog; it is NOT given aria-expanded (a modal is not a disclosure)
- Close affordances: a header close button, any element with data-md-close, the Escape key, and a click on the backdrop (but NOT a click inside the dialog)

FOCUS MANAGEMENT (the hard part — get this exactly right)
- On open: store document.activeElement as the "return target"
- Move focus into the dialog: prefer the first element marked [autofocus], else the first focusable element, else the dialog container itself (give it tabindex="-1")
- FOCUS TRAP: on keydown Tab, compute the dialog's focusable elements (a[href], button:not([disabled]), input/select/textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), and filter out hidden ones). If Shift+Tab on the first element, wrap to the last; if Tab on the last element, wrap to the first. preventDefault on the wrap
- Recompute focusable elements on each Tab (the dialog content may change), do not cache a stale list
- On close: call focus() on the stored return target so the user lands back on the trigger

SCROLL LOCK
- On open: set documentElement overflow to hidden; compensate for the removed scrollbar by adding padding-right equal to (window.innerWidth - documentElement.clientWidth) so the page does not shift
- On close: restore the previous overflow and padding-right exactly (save and restore original inline values; do not assume they were empty)
- Reference-count locks so multiple stacked dialogs do not unlock the scroll prematurely

INTERACTION DETAILS
- Escape closes the topmost dialog only (track an open stack so nested/stacked dialogs close one at a time)
- Backdrop click closes; use pointerdown on the overlay and confirm the target IS the overlay element (not a child) so a drag that starts inside the dialog and releases on the backdrop does NOT close it
- Clicking inside the dialog never closes it

API (the class)
- new Modal(dialogOverlayElement, {
    onOpen: () => {},
    onClose: () => {},
    closeOnBackdrop: true,   // default true
    closeOnEscape: true      // default true
  })
- Public methods: open(), close(), isOpen(), destroy()
- destroy() removes all listeners and, if open, restores scroll + focus
- Dispatches 'modal:open' and 'modal:close' CustomEvents on the overlay element
- Wire triggers declaratively too: a [data-md-open="dialogId"] button opens the matching overlay

ACCESSIBILITY
- Real DOM focus management (no aria-activedescendant); focus must be visible at every step
- Maintain a 4.5:1 contrast ratio for text; the cyan primary button on dark must stay legible
- Touch targets at least 40px; close button at least 32px
- prefers-reduced-motion disables animation and transitions

SECURITY
- Never inject untrusted strings with innerHTML; set text via textContent and build nodes with createElement
- The component manipulates only existing DOM and attributes — no eval, no inline event-handler strings, no remote code

OUTPUT
- Single self-contained HTML file with embedded <style> and <script>
- TWO live demos sharing the SAME Modal class: a form dialog (email + role inputs) and a destructive confirm dialog
- Both must demonstrate the focus trap, Escape, backdrop click, scroll lock, and focus restoration
- Polished, professional aesthetic — should feel like the dialogs in a premium modern SaaS dashboard
  1. CopyThe full prompt, one click.
  2. PasteInto Cursor, Claude Code, or any agent.
  3. CompareAgainst the live demo above.

What’s Inside

01

Trigger Button

The control that opens the dialog and the element focus returns to on close — stored as document.activeElement at open time.

02

Backdrop Overlay

A dimmed, blurred full-screen layer. A click on the backdrop itself (not its children) dismisses the dialog.

03

Dialog Container

role="dialog" with aria-modal="true", labelled by its title and described by its body text for assistive tech.

04

Focus Trap

Tab and Shift+Tab cycle only among the dialog's focusable elements, wrapping at each end so focus never escapes.

05

Scroll Lock

Background scrolling is disabled while open, with scrollbar-width compensation so the page never shifts.

06

Close Affordances

A header × button, any data-md-close element, the Escape key, and a backdrop click all dismiss the dialog.

07

Focus Restoration

On close, focus() is called on the element that opened the dialog, so keyboard users land exactly where they were.

08

Open Stack

Stacked dialogs are reference-counted so Escape closes only the topmost and the scroll lock releases at the right time.

Usage Guidelines

Use This When

  • You need a confirmation, a focused form, or any flow that should block the page until the user decides
  • You want full control over styling and enter/exit animation that the native <dialog> does not give you, while keeping correct ARIA
  • Accessibility matters — you need a real focus trap, Escape handling, scroll lock, and focus restoration out of the box

Not Ideal When

  • The native <dialog> with showModal() already covers your need — it gives a free focus trap and top-layer rendering
  • You only need a transient, non-blocking message — use a toast or inline status instead of a modal
  • The content is large or multi-step — consider a full page or a side drawer so users are not boxed into a small overlay
Modal & Dialog
Copied to clipboard