AccessScanRun a free scan

Guide

How to Build an Accessible Modal Dialog

Modals are one of the most common places accessibility quietly breaks. A keyboard user gets stranded behind the overlay, a screen reader keeps reading the page underneath, or pressing Escape does nothing. The good news is that an accessible modal dialog is far easier to build correctly today than it was a few years ago, thanks to the native HTML dialog element.

This guide walks through what a robust modal actually needs: the dialog element and showModal(), trapping focus, restoring focus when the modal closes, wiring up Escape, the right ARIA, and the mistakes that show up most often in real audits.

Start with the native dialog element

Before reaching for a library or a custom <div>, use the platform. The <dialog> element opened with the showModal() method gives you most modal behavior for free, and it is supported across all current major browsers.

Calling dialog.showModal() does three things that are genuinely hard to replicate by hand: it renders the dialog in the browser's top layer (so it sits above everything without z-index fights), it makes the rest of the document inert (background content is not focusable or clickable), and it exposes the element as a modal to assistive technology. Closing with dialog.close() or the form pattern below tears all of that down cleanly.

A minimal, correct structure looks like this:

  • <dialog id="confirm" aria-labelledby="confirm-title"> — the container, named by its heading
  • <h2 id="confirm-title">Delete project?</h2> — the visible title that names the dialog
  • <form method="dialog"> with a <button value="cancel"> and <button value="confirm"> — submitting closes the dialog and records which button was used via dialog.returnValue

The key distinction: showModal() creates a true modal. The older show() method (and the open attribute) creates a non-modal dialog that does not trap focus or make the background inert, so it is not what you want for a blocking confirmation or a form overlay.

Focus trapping and restoring focus

Trapping focus inside the modal

While a modal is open, Tab and Shift+Tab must cycle only through the controls inside it. With showModal() the browser handles this automatically because everything outside the top layer is inert. This is the single biggest reason to prefer the native element: hand-rolled focus traps are notoriously buggy, especially around iframes, shadow DOM, and dynamically added content.

If you cannot use <dialog> and must build on a <div>, you have two options. The cleaner one is to set the inert attribute on every sibling of the modal (or on the main app wrapper), which removes that content from focus and the accessibility tree in one move. The harder one is a JavaScript trap: query all focusable elements, listen for Tab on the first and last, and loop focus manually. Reach for inert first.

Restoring focus on close

This step is skipped constantly, and it badly hurts keyboard users. When the modal closes, focus must return to the element that opened it, usually the trigger button. Otherwise focus falls back to the top of the document and the user has to tab through the whole page again to find their place.

Capture the trigger before opening and restore it after closing: const opener = document.activeElement; then, in a close event listener, opener.focus();. Native <dialog> does not do this for you, so it is one of the few pieces of glue code you still must write. Predictable focus order is required by WCAG 2.4.3 Focus Order (Level A), and it pairs directly with a visible focus indicator (2.4.7 Focus Visible, AA) so users can see where they have landed.

Escape to close and modal semantics

Escape closes the dialog

Users expect Escape to dismiss a modal, and a keyboard route out is part of meeting WCAG 2.1.1 Keyboard (Level A). With showModal() this is built in: pressing Escape fires a cancel event and then closes the dialog, no key handling required. If you build a custom modal, you must add a keydown listener for the Escape key yourself, and make sure it does not also close a parent menu or trigger unrelated shortcuts.

A common refinement: for forms with unsaved changes, listen for the cancel event and call event.preventDefault() to confirm before discarding. Just be sure there is always a way out, do not trap users in a modal they cannot leave.

Get the ARIA naming right

When you use showModal(), the browser already conveys modal semantics, so you do not manually add role="dialog" or aria-modal="true"; doing so can double up announcements. What you do still owe is an accessible name. Point aria-labelledby at the visible heading, or use aria-label if there is no visible title. Add aria-describedby when a body paragraph should be read as part of the dialog's introduction.

If you are stuck on a custom <div> implementation, then you do need the full set: role="dialog", aria-modal="true", and a name via aria-labelledby or aria-label. Native <dialog> is almost always the safer choice.

Common mistakes to avoid

  • Hiding the background with CSS only. Setting opacity or display on an overlay does not stop Tab from reaching links behind it. Use <dialog> with showModal(), or inert.
  • Forgetting to restore focus. After close, focus must return to the trigger, not jump to the page top.
  • Using a non-semantic close control. An × glyph in a <span> is invisible to screen readers and not keyboard-focusable. Use a real <button> with an accessible name like "Close dialog".
  • Tiny tap targets. The close button and any actions should meet WCAG 2.2's 24x24 CSS pixel minimum target size (2.5.8 Target Size Minimum, AA), with enough spacing between them.
  • Low-contrast overlays and buttons. Dialog text needs 4.5:1 (1.4.3), and buttons, icons, and the focus outline need 3:1 against their background (1.4.11). Verify with our contrast checker.
  • Auto-focusing a destructive action. Do not put initial focus on Delete or Confirm, an accidental Enter is destructive. Focus the title, the first field, or the safe default.
  • Letting a focused element get hidden under sticky UI. WCAG 2.2's 2.4.11 Focus Not Obscured (Minimum, AA) means the focused control must stay at least partly visible.

If you maintain a component library, dialogs are worth auditing once and reusing everywhere. You can scan any page for these issues, missing names, focus problems, contrast failures, with the free checker at AccessScan, which flags the most common modal regressions automatically. For broader coverage, the accessibility checklist walks through related interactive patterns.

Why this matters for compliance

Modal accessibility is not just polish. Keyboard operability (2.1.1, Level A), focus order (2.4.3, Level A), a visible focus indicator (2.4.7, AA), and focus not being obscured (2.4.11, AA) are all real success criteria. Under the European Accessibility Act, whose requirements apply from 28 June 2025, the baseline is WCAG 2.2 Level A and AA via EN 301 549. A modal that traps keyboard users or hides focus is a concrete failure against that baseline.

The same criteria underpin ADA-related expectations in the US, where WCAG 2.1/2.2 Level AA is the de-facto standard used in settlements and guidance, so getting dialogs right pays off across jurisdictions. Building on the native <dialog> element, restoring focus, and respecting Escape gets you most of the way to a compliant, genuinely usable accessible modal dialog.

Check your site against AccessScan

See your issues ranked by impact in seconds — free.

Run a free accessibility scan

FAQ

Do I still need ARIA if I use the native dialog element?

Mostly no. A dialog opened with showModal() is announced as a modal and exposes aria-modal semantics automatically, so you do not add role="dialog" or aria-modal="true" yourself. You should still provide an accessible name with aria-labelledby (pointing at the dialog's heading) or aria-label, and use aria-describedby if a description should be announced. Adding redundant roles can actually confuse screen readers.

Should focus go to the dialog itself or to the first input?

Move focus into the dialog, but be deliberate. For a simple confirmation, focusing the dialog container or its heading lets the screen reader read the title first. For a form, focus the first input or the autofocus element. Avoid focusing a destructive button (like Delete) by default, since an accidental Enter could trigger it.

Is the native dialog element accessible enough on its own for WCAG and the EAA?

The native dialog handles focus trapping, Escape, the top layer, and modal semantics, which covers the hardest parts. You still must give it an accessible name, restore focus to the trigger on close, ensure a visible focus indicator (2.4.7, 2.4.11), and meet target size (2.5.8) and contrast on its controls. Done with those additions, it satisfies the relevant WCAG 2.2 Level A and AA criteria that EN 301 549 and the EAA reference.

Why does my custom modal still let users Tab to the page behind it?

Because visually hiding content does not remove it from the tab order or the accessibility tree. With a custom div you must either implement a focus trap in JavaScript or apply inert to everything outside the modal. The native dialog with showModal() solves this for free by rendering in the top layer and making the rest of the document inert.

More guides