Patterns Modal dialog

Modal dialog

A focused, interruptive layer over the page. Uses the native <dialog> element so focus, Escape, and backdrop inertness all just work.

Reviewed against the methodology checklist Updated

Try the keyboard

When to use

A modal dialog interrupts the user. Reach for it when the work that follows is both focused (one decision) and must complete or cancel before they continue. Confirmations, “edit this thing”, login prompts, payment flows. If the content is browseable or non-blocking, use a non-modal disclosure (popover, expanding section) instead.

The pattern

Accessible modal

Pretend this is a real form. The point is the focus trap, the Escape key, and the way the page behind goes inert — all from the native <dialog>.

The native <dialog> element with .showModal() does an enormous amount of work for you. It:

HTML
<button type="button" data-modal-open>Edit profile</button>

<dialog aria-labelledby="edit-title">
<form method="dialog">
  <h2 id="edit-title">Edit profile</h2>
  <button type="button" aria-label="Close" data-modal-close>×</button>
  <!-- form fields here -->
  <button type="button" data-modal-close>Cancel</button>
  <button type="submit">Save</button>
</form>
</dialog>
JS
const dialog = document.querySelector('dialog');
document.querySelector('[data-modal-open]')
.addEventListener('click', () => dialog.showModal());

dialog.querySelectorAll('[data-modal-close]')
.forEach((btn) => btn.addEventListener('click', () => dialog.close()));

The <form method="dialog"> trick makes pressing Enter in a form field, or activating a <button type="submit">, close the dialog and return its returnValue. Pair that with the explicit Cancel button (which calls dialog.close()) and you have a complete keyboard story.

Naming the dialog

Give the dialog an accessible name with aria-labelledby pointing at the visible title. If there isn’t a visible title, use aria-label. Without one, screen readers announce “dialog” with no context — the user has no idea what they’re looking at.

Initial focus

.showModal() moves focus to the first sequentially focusable element. That’s usually fine — but for destructive flows (“Delete this account?”), you want focus on Cancel, not on the destructive button. Either reorder the DOM, or call .focus() on the desired element after .showModal().

Anti-pattern: the rolled-your-own overlay

Every component library has one of these. A <div> styled to look like a dialog, layered with position: fixed and a black overlay. Nothing else.

Don’t do this

What’s missing:

  1. No focus management. Opening the “dialog” leaves focus on the trigger button behind it. Tab continues to traverse the page underneath.
  2. No focus trap. A keyboard user can Tab right out of the dialog into the page beneath.
  3. Page behind isn’t inert. A screen reader user can swipe straight into the underlying content with no idea they’re “behind” something.
  4. Escape does nothing. You have to add a keydown listener yourself, and remember to remove it on close.
  5. No accessible name. role="dialog" with no aria-labelledby or aria-label is announced as just “dialog” — meaningless.
  6. Fake controls. <span> and <div> with click handlers — not focusable, no role, no keyboard activation.

Recreating what <dialog> gives you is a long file of focus management, keyboard listeners, ARIA wiring, and inert toggling. Or you type <dialog>.

Common variations

Checklist

Screen reader transcript

Voiced by George — reading what VoiceOver on macOS Safari announces. Other readers (NVDA, JAWS) phrase things differently; the meaning is what matters.

Screen reader announcements for each step
ListenYou doScreen reader says
Activate the trigger(focus moves into the dialog)
First control inside is announcedEdit profile, dialog. Close dialog, button.
Tab through the dialog(focus stays trapped inside the dialog)
Press Escape(dialog closes; focus returns to the trigger)

Screen reader transcript — anti-pattern

Same interaction, but on the broken version above. Notice what the user is missing.

Screen reader announcements for each step
You doScreen reader says
Activate the fake trigger(the dialog appears; focus stays on the trigger button behind it)
Press Tab(focus moves to the page behind the overlay — the user can interact with it)
Press Escape(nothing happens — Escape only works if you wire it up)
Reach the dialog with Tab(no name announced; sounds like a stack of unrelated text)

WCAG references