Design.dev design.dev
UI Component Prompt

Modal & Dialog

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

Copy → Paste → Ship

Live Preview

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

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

Anatomy

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

Frequently Asked Questions

How do I build an accessible modal dialog without a library?

Render an overlay with role="dialog", aria-modal="true", and aria-labelledby pointing at the title. On open, store the element that had focus, move focus into the dialog, and trap Tab/Shift+Tab so focus cycles only among the dialog's focusable elements. Close on Escape and on backdrop click, lock background scrolling, and restore focus to the trigger on close. The prompt above outputs this full WAI-ARIA dialog pattern in vanilla JavaScript.

What is a focus trap and why does a modal need one?

A focus trap keeps keyboard focus inside the open dialog so Tab does not wander to the page behind it. Without it, keyboard and screen-reader users can tab to hidden background controls, breaking the modal. The component computes the focusable elements inside the dialog and, on Tab from the last (or Shift+Tab from the first), wraps focus back to the other end.

Should I use the native HTML dialog element instead?

The native <dialog> with showModal() gives you a free focus trap, top-layer rendering, and a ::backdrop, and it is a great default. This prompt builds a custom dialog when you need full control over enter/exit animations, styling parity across older browsers, or behavior the native element does not expose. Both follow the same ARIA semantics, so the accessibility contract is identical.

How do I stop the background from scrolling when a modal is open?

Add overflow: hidden to the document element while the dialog is open and remove it on close. To avoid a jump from the disappearing scrollbar, measure window.innerWidth - documentElement.clientWidth and apply it as padding-right compensation. The prompt handles this scroll lock and restores the original styles on close.

How should keyboard and focus behave when the modal closes?

When the dialog opens it remembers document.activeElement; when it closes it calls focus() on that stored element so the user lands back exactly where they were. Escape, the close button, and a backdrop click all close it, and focus never leaves the dialog while open. This round-trip is what makes a modal feel correct to keyboard and screen-reader users.

Can I use this modal with React, Vue, or Svelte?

Yes. The output is framework-agnostic vanilla JavaScript exposing a Modal class with open(), close(), and destroy(). In React, instantiate inside useEffect with a ref; in Vue, inside onMounted; in Svelte, inside onMount. Call destroy() on unmount to remove listeners and restore body styles, avoiding memory leaks and a stuck scroll lock.

What is the difference between a modal and a non-modal dialog?

A modal dialog (aria-modal="true") blocks interaction with the rest of the page until dismissed, traps focus, and dims the background. A non-modal dialog lets the user keep interacting with the page behind it and does not trap focus. This prompt builds the modal variant — the right choice for confirmations, forms, and anything that demands a decision before continuing.

Copied to clipboard