Accessible Modal Dialog Pattern

A fully accessible modal dialog built on the native HTML <dialog> element. Focus trapping, keyboard dismissal, and screen reader announcements — all with minimal code.

Native HTML WCAG 2.2 AA

Live Demo

Click "Open Modal Dialog" to see the accessible pattern. Then try "Open Inaccessible Version" to experience the difference a <div>-based modal makes.

Confirm Your Action

This modal uses the native <dialog> element. Try these interactions:

  • Press Escape to close
  • Press Tab — focus stays trapped inside
  • Click the backdrop to close
  • Notice focus returns to the trigger button on close

Inaccessible Modal

This <div>-based modal has several accessibility problems:

  • Escape does nothing
  • Tab escapes to background elements
  • Screen readers don't announce this as a dialog
  • Focus isn't managed on open or close

The Code

HTML
<!-- Trigger button -->
<button type="button" id="open-dialog">
  Open Modal
</button>

<!-- The dialog -->
<dialog id="my-dialog" aria-labelledby="my-dialog-title">
  <div class="dialog-header">
    <h2 id="my-dialog-title">Dialog Title</h2>
    <button type="button" class="dialog-close" aria-label="Close dialog">
      &times;
    </button>
  </div>
  <div class="dialog-body">
    <p>Your dialog content here.</p>
  </div>
  <div class="dialog-footer">
    <button type="button" class="btn-cancel">Cancel</button>
    <button type="button" class="btn-confirm">Confirm</button>
  </div>
</dialog>
CSS
dialog {
  border: 1px solid #d1d5db;
  border-radius: 0.75rem;
  padding: 0;
  max-width: 32rem;
  width: calc(100% - 2rem);
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}

dialog::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.dialog-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.5rem;
  border-bottom: 1px solid #d1d5db;
}

.dialog-body {
  padding: 1.5rem;
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
  padding: 1.5rem;
  border-top: 1px solid #d1d5db;
}

/* Close button: meets 24x24 target size (SC 2.5.8) */
.dialog-close {
  width: 2.75rem;
  height: 2.75rem;
  border: none;
  background: transparent;
  border-radius: 0.5rem;
  cursor: pointer;
  font-size: 1.25rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.dialog-close:hover {
  background: #f3f4f6;
}

/* Focus styles: meets SC 2.4.13
   2px outline, 3:1 contrast ratio */
dialog :focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: 2px;
  border-radius: 0.25rem;
}
JavaScript
const dialog = document.getElementById('my-dialog');
const openBtn = document.getElementById('open-dialog');
const closeBtn = dialog.querySelector('.dialog-close');
const cancelBtn = dialog.querySelector('.btn-cancel');

// Open: use showModal() for true modal behavior
openBtn.addEventListener('click', () => {
  dialog.showModal();
});

// Close: dialog.close() automatically returns
// focus to the element that was focused before
// showModal() was called
closeBtn.addEventListener('click', () => {
  dialog.close();
});

cancelBtn.addEventListener('click', () => {
  dialog.close();
});

// Close on backdrop click
dialog.addEventListener('click', (event) => {
  if (event.target === dialog) {
    dialog.close();
  }
});

// Note: Escape key closes the dialog automatically
// when using showModal() — no JS needed for that.

Why These Decisions

Why <dialog> instead of a <div>?

The native <dialog> element gives you critical modal behavior for free:

  • Focus trappingTab cycles through focusable elements inside the dialog only
  • Escape key — closes the dialog automatically
  • Inert background — content behind the dialog becomes non-interactive
  • Role announcement — screen readers announce "dialog" when it opens
  • Backdrop — the ::backdrop pseudo-element provides a visual overlay

Building all of this from scratch with a <div> requires hundreds of lines of JavaScript and is easy to get wrong.

Why showModal() instead of show()?

dialog.show() opens the dialog as a non-modal popup — it doesn't trap focus, doesn't add a backdrop, and doesn't make the background inert. dialog.showModal() provides true modal behavior. Use show() only for non-modal popovers where users should still interact with the page behind.

Why aria-labelledby?

When a dialog opens, screen readers announce its role ("dialog") and its accessible name. Without aria-labelledby pointing to the heading, the dialog has no name — users hear "dialog" but don't know which dialog or what it's for. The heading provides that context.

Why aria-label="Close dialog" on the close button?

The close button uses a visual "×" character, which screen readers might announce as "times" or "multiplication sign." The aria-label overrides this with a clear action description.

Why close on backdrop click?

Users expect to dismiss a modal by clicking outside it. The native <dialog> doesn't do this automatically — clicking the backdrop does nothing by default. The event listener on the dialog element checks if the click target is the dialog itself (the backdrop area) and closes it.

What about aria-modal="true"?

Don't add it to the native <dialog>. When opened with showModal(), the element is already implicitly modal — the browser inerts the background and screen readers scope virtual cursor navigation correctly. aria-modal="true" is only needed when you've built a modal with <div role="dialog">, where it tells assistive tech to treat sibling content as inert. Adding it to the native element is at best redundant; in a few screen reader combinations it has caused duplicate announcements.

Modal vs. non-modal dialog

The same <dialog> element supports both modes. Call dialog.show() for a non-modal popup (no backdrop, no focus trap, page behind stays interactive) — good for transient panels like a date-picker calendar. Call dialog.showModal() for a true modal — the user must dismiss it before doing anything else. If you're unsure which to use, the rule of thumb is: modal for "must answer now", non-modal for "here when you want it". For alert-style modals that interrupt the user with an urgent message, see the alertdialog pattern.

Keyboard Interaction

Key Action
Enter or Space Activates the trigger button to open the dialog
Escape Closes the dialog (built into native <dialog>)
Tab Moves focus to the next focusable element inside the dialog. Focus wraps from the last to the first element.
Shift + Tab Moves focus to the previous focusable element inside the dialog. Focus wraps from the first to the last element.
Focus Management When showModal() is called, the browser automatically moves focus to the first focusable element in the dialog. When the dialog closes, focus returns to the element that was focused before the dialog opened. No JavaScript needed for either behavior.

What WCAG Requires for Modal Dialogs

WCAG 2.2 doesn't have a single "modal dialog" success criterion — modal accessibility is the result of several criteria working together. The four that matter most for modals are 2.1.1 Keyboard (everything works without a mouse), 2.1.2 No Keyboard Trap (focus must always be releasable, which is why Escape closes the dialog), 2.4.3 Focus Order (focus moves into the dialog on open and back to the trigger on close), and 4.1.2 Name, Role, Value (the dialog has a role of "dialog" and an accessible name).

The native <dialog> element handles the role announcement and focus order automatically when you call showModal(). You provide the accessible name with aria-labelledby. Escape dismissal is built in — so 2.1.2 No Keyboard Trap is satisfied by default, not by adding code. See our focus management guide for the deeper reasoning behind why each step matters.

The full list of WCAG 2.2 success criteria this pattern addresses:

  • 2.1.1 Keyboard Level A — All dialog functionality is operable via keyboard. Open, close, and interact with dialog content without a mouse.
  • 2.1.2 No Keyboard Trap Level A — Focus is trapped inside the dialog while open, but Escape always closes it and releases focus.
  • 2.4.3 Focus Order Level A — Focus moves into the dialog on open and returns to the trigger on close, preserving logical focus order.
  • 2.4.11 Focus Not Obscured (Minimum) Level AA — Focused elements within the dialog are fully visible, not hidden behind other content.
  • 2.4.13 Focus Appearance Level AAA — Focus indicators use a 2px solid outline with 3:1 contrast ratio against adjacent colors.
  • 2.5.8 Target Size (Minimum) Level AA — All interactive elements (close button, action buttons) meet the 24×24px minimum target size.
  • 4.1.2 Name, Role, Value Level A — The dialog has a role of "dialog" (native) and an accessible name via aria-labelledby.

Screen Reader Behavior

When the dialog opens

  • NVDA: "Confirm Your Action, dialog" — announces the dialog role and its label
  • JAWS: "Confirm Your Action dialog" — similar announcement
  • VoiceOver: "Confirm Your Action, web dialog" — adds "web" prefix to distinguish from system dialogs

Navigating inside

Screen reader users can use Tab to move through focusable elements or use virtual cursor navigation to read all content. The content behind the dialog is marked as inert, so virtual cursor navigation is restricted to the dialog content only.

When the dialog closes

Focus returns to the trigger button. Screen readers announce the newly focused element, restoring context so the user knows where they are on the page.

Testing this yourself The fastest way to verify modal accessibility is to open the live demo above with a screen reader running and try every interaction with the keyboard only. The screen reader testing guide walks through setup for NVDA, JAWS, and VoiceOver — and the accessibility testing checklist covers the specific items to verify for any modal in production.

Common Mistakes

Using <div> with role="dialog" instead of <dialog> Adding role="dialog" to a <div> only gives you the semantic label — you still need to manually implement focus trapping, Escape key handling, inert background, and focus restoration. The native <dialog> element handles all of these automatically.
Using dialog.show() instead of dialog.showModal() show() opens the dialog as a non-modal element. Focus isn't trapped, the background isn't inert, and there's no backdrop. Unless you specifically need a non-modal popup, always use showModal().
Not returning focus to the trigger If you programmatically manage focus (rather than relying on the native behavior), failing to return focus to the trigger button on close leaves keyboard users stranded — often at the top of the page or at an unexpected location.
Missing accessible name A dialog without aria-labelledby or aria-label is announced as just "dialog" — screen reader users don't know what it's for. Always connect the dialog to its heading.
Autofocus on the close button Putting autofocus on the close button means the first thing a screen reader user hears is "Close" with no context. Let focus land on the first logical element (often the heading area or first interactive element relevant to the dialog's purpose).
Animated entry without respecting prefers-reduced-motion If you animate the dialog's entry (fade, slide, scale), always wrap animations in a @media (prefers-reduced-motion: no-preference) query. Users who have enabled reduced motion preferences should see the dialog appear instantly.

Native HTML vs. ARIA

This comparison shows what you get for free with the native <dialog> element versus what you'd need to build manually with a <div role="dialog">.

Feature <dialog> (native) <div role="dialog">
Dialog role Automatic Manual (role="dialog")
Focus trapping Automatic with showModal() Manual JS (~50 lines)
Escape to close Automatic with showModal() Manual keydown listener
Inert background Automatic with showModal() Manual inert attribute or aria-hidden on siblings
Focus restoration Automatic on close() Manual — track and restore trigger element
Backdrop ::backdrop pseudo-element Manual overlay <div>
Accessible name Needs aria-labelledby Needs aria-labelledby
Backdrop click to close Manual (small event listener) Manual (small event listener)
Browser support All modern browsers (96%+) All browsers
The verdict The native <dialog> element eliminates roughly 80% of the JavaScript you'd need with a <div>-based approach. The only ARIA attribute needed is aria-labelledby — which both approaches require equally. Use the native element.

Frequently asked questions

What's the difference between a modal and a dialog?

A dialog is any small window that asks for input or shows information. A modal is a dialog that blocks interaction with the rest of the page until it's dismissed. In HTML, the same <dialog> element handles both: dialog.show() opens it as a non-modal popup (user can still interact with the page behind it), while dialog.showModal() opens it as a true modal (focus trapped, background inert, backdrop visible). Most of the time when people say "modal" they mean a modal dialog — the two terms are used interchangeably in practice.

Is the HTML <dialog> element accessible?

Yes — when opened with showModal() and given an accessible name via aria-labelledby, it provides built-in focus trapping, Escape dismissal, an inert background, focus restoration to the trigger, and a proper role="dialog" announcement. It's supported in all modern browsers (Chrome, Edge, Firefox, Safari) since early 2022, covering over 96% of users. The remaining accessibility work is small: connect the dialog to its heading with aria-labelledby, give the close button an accessible name, and optionally add backdrop-click-to-close. Compared to a hand-rolled <div role="dialog">, the native element eliminates roughly 50 lines of JavaScript that are easy to get wrong.

What does WCAG say about modal dialogs?

WCAG 2.2 doesn't have a single "modal" criterion — instead, several success criteria apply together. The most important are 2.1.1 Keyboard (all functionality must work without a mouse), 2.1.2 No Keyboard Trap (focus must be releasable, which is why Escape always closes the dialog), 2.4.3 Focus Order (focus must move into the dialog on open and back to the trigger on close), 2.4.11 Focus Not Obscured (Level AA — focused elements inside the dialog must be fully visible), and 4.1.2 Name, Role, Value (the dialog needs an accessible name and a role of "dialog"). The native <dialog> element handles role and focus order automatically; you provide the accessible name.

How do I make a modal keyboard accessible?

Three things must work: (1) the trigger button must open the modal with Enter or Space (use a real <button> — never a clickable <div>), (2) focus must move into the modal when it opens and stay trapped inside it while open, and (3) Escape must close the modal and return focus to the trigger. The native <dialog> element gives you (2) and (3) for free when you call showModal(). The only thing you have to add is the click handler that opens it. See the focus management guide for the deeper why.

Should I use aria-modal="true" on the <dialog> element?

No. The native <dialog> element, when opened with showModal(), is implicitly modal — the browser handles the background-inerting behavior and screen readers know to scope virtual cursor navigation to the dialog. Adding aria-modal="true" is redundant and, in some screen reader / browser combinations, has caused duplicate announcements. aria-modal="true" is only needed on a <div role="dialog"> where you've built modal behavior manually — it tells assistive tech to treat siblings as inert, which the native element does automatically.

Can I use the native <dialog> element in production?

Yes. As of 2024, <dialog> with showModal() is supported in Chrome, Edge, Firefox, and Safari — over 96% of users globally. The remaining ~4% of users on older browsers will see the dialog still render (as a static element) but without the modal behaviors. For most production sites, that's acceptable graceful degradation. If you must support browsers older than mid-2022, you can either polyfill (a maintained polyfill is available from the GoogleChrome organization) or fall back to a <div role="dialog"> implementation. For new code, the native element is the right default.

How do I trap focus inside a modal?

If you use the native <dialog> element with dialog.showModal(), focus trapping is automatic — Tab cycles between focusable elements inside the dialog and never escapes to the background. No JavaScript needed. If you're stuck using a <div role="dialog">, you'll need to: (1) listen for Tab keydown, (2) find all focusable elements inside the dialog (links, buttons, inputs, [tabindex] elements), (3) on Tab from the last focusable element, prevent default and focus the first; on Shift+Tab from the first, focus the last. Also set inert on sibling elements to keep screen reader virtual cursor inside. This is exactly the kind of code that's easy to get subtly wrong — another reason to prefer the native element.

When should I avoid using a modal?

Modals interrupt the user — they should be reserved for tasks that genuinely require the user's full attention or where work would be lost if dismissed casually. Avoid modals for: (1) marketing prompts (newsletter signups, cookie banners — use an inline banner instead), (2) showing read-only information that doesn't require a decision (use a disclosure, accordion, or new page), (3) multi-step flows with more than 2-3 steps (route to a dedicated page instead — modals don't support browser back/forward well), and (4) anything that needs to be linkable or shareable (a modal isn't a URL). Good modal use cases: confirmation of destructive actions, short forms tightly coupled to the current page context, and image lightboxes.