Skip to content
Back to blog
WCAGhow-todialogs

How to build an accessible modal dialog

Focus management, Escape, ARIA and the native dialog element: how to build an accessible modal dialog, with the WCAG criteria behind each part.

P

Pavel Charkasau

An accessible modal dialog gets five things right. Focus moves into it when it opens. Focus stays inside while it is open. Escape closes it. Focus returns to the control that opened it. And the page behind it is genuinely inert, not just dimmed. The shortest route to all five is the native <dialog> element opened with showModal(), which puts the dialog in the browser's top layer, makes the rest of the document inert, handles Escape, and exposes aria-modal="true" without you writing any ARIA (MDN). If a framework or a design constraint rules that out, you rebuild each behaviour by hand: role="dialog", aria-modal="true", an aria-labelledby pointing at the visible title, a Tab loop, and the inert attribute on everything else (W3C ARIA APG). The success criteria you are measured against are 2.4.3 Focus Order and 2.1.2 No Keyboard Trap at Level A, plus 4.1.2 Name, Role, Value. This guide walks through each behaviour in the order you build it.

What makes a modal dialog accessible?

A modal is accessible when someone using only a keyboard, or a keyboard and a screen reader, can tell that a dialog opened, read it, act on it, and get back out. Nothing about that is exotic. It goes wrong because a modal is really four separate behaviours that have to agree with each other, and most component libraries implement three of them: an accessible name, focus placement on open, containment while open, restoration on close. Miss the name and a screen reader user hears "dialog" and nothing else. Miss the containment and Tab walks them out into the page behind, where they are reading content they cannot click. That second failure is the common one, and it is invisible to anyone testing with a mouse.

WCAG covers this across a few criteria rather than one. 2.4.3 Focus Order (Level A) requires that focus order preserve meaning and operability, and the W3C's own guidance describes the expected modal behaviour directly: focus transfers into the dialog, the rest of the page becomes inert, and on dismissal focus returns to the trigger (W3C). 4.1.2 Name, Role, Value (Level A) is what the accessible name satisfies. And 2.4.11 Focus Not Obscured (Minimum), new at Level AA in WCAG 2.2, is the one modals usually pass for free, because a modal takes focus when it appears (W3C).

Should I use the native dialog element or build my own?

Use the native element. <dialog> has been Baseline widely available since March 2022, and showModal() does work that is genuinely awkward to reproduce (MDN).

<button id="open-prefs">Notification settings</button>

<dialog id="prefs" aria-labelledby="prefs-title">
  <h2 id="prefs-title">Notification settings</h2>
  <p>Choose how often we email you about new scan results.</p>
  <form method="dialog">
    <button value="cancel" autofocus>Cancel</button>
    <button value="save">Save</button>
  </form>
</dialog>
const dialog = document.getElementById("prefs");
document.getElementById("open-prefs")
  .addEventListener("click", () => dialog.showModal());

That is the whole thing. showModal() promotes the dialog to the top layer, so it renders above everything without a z-index argument; it renders the rest of the document inert, so background links are unclickable and unreachable by Tab; and Escape closes it. form method="dialog" closes the dialog on submit and reports which button did it via returnValue.

Two details worth knowing. Do not put tabindex on the <dialog> element itself, because it is a container, not a control (MDN). And show() is not the modal method. A dialog opened with show() or with the bare open attribute exposes aria-modal="false", does not go to the top layer, and does not close on Escape. If you meant modal, call showModal().

There is a newer closedby attribute for controlling dismissal. closedby="any" adds light dismiss, so a click on the backdrop closes the dialog; closedby="none" removes Escape too, which you would only want for something a user genuinely must answer. Its support is newer than the element's own, so layer it on rather than depend on it (MDN).

What ARIA does a custom dialog need?

When you cannot use <dialog>, the APG pattern spells out the contract. The container takes role="dialog" with aria-modal="true", and it must have either aria-labelledby referring to a visible dialog title or an aria-label (W3C ARIA APG).

<div role="dialog" aria-modal="true" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete this scan?</h2>
  <p>The report and its evidence screenshots will be removed.</p>
  <button type="button">Cancel</button>
  <button type="button">Delete</button>
</div>

aria-describedby is optional and easy to misuse. The APG says to omit it when the dialog contains lists, tables, or several paragraphs, because the description gets announced as one unbroken string and long content becomes unreadable that way. A single sentence of context is a good candidate. A paragraph of terms is not.

The aria-modal="true" attribute carries a real obligation. The APG is blunt about it: because setting it can stop some assistive technologies from perceiving anything outside the dialog, "users of those technologies will experience severe negative ramifications if a dialog is marked modal but does not behave as a modal for other users." Mark it modal only when your code actually prevents interaction with the content outside it and the styling obscures that content. A dimmed backdrop with no inertness behind it is worse than an honest non-modal panel, because it tells the screen reader the page is gone while the keyboard says otherwise.

For the inertness itself, use the inert attribute on the background wrapper. It removes descendants from the tab order and from the accessibility tree, blocks clicks, and even blocks find-in-page (MDN). It has been widely available since April 2023, which makes the old approach of stamping aria-hidden="true" onto every sibling largely unnecessary.

How do I manage focus when a dialog opens and closes?

On open, move focus to an element inside the dialog. The APG's default is the first focusable element, with two exceptions worth applying: when the dialog leads with semantic content such as a list or table, focus a static element at the start of that content carrying tabindex="-1", and when the dialog is long, focus the title so the user does not land halfway down a scrolled panel (W3C ARIA APG).

For a destructive confirmation, put initial focus on the safe option. autofocus on the Cancel button, as in the example above, is one attribute and it stops an over-eager Enter press from deleting something.

On close, return focus to the element that opened the dialog. The native element does not do this part for you in every case, so store the trigger and restore it:

let lastTrigger = null;

openButton.addEventListener("click", (event) => {
  lastTrigger = event.currentTarget;
  dialog.showModal();
});

dialog.addEventListener("close", () => {
  lastTrigger?.focus();
});

If the trigger no longer exists, for example a row's delete button after the row was deleted, focus the nearest sensible thing instead. The table heading, or the container the row lived in, with tabindex="-1" so it can take focus programmatically. What you must not do is let focus fall back to <body>, because the user's position in the page is then gone and a screen reader restarts from the top.

The APG also strongly recommends the tab sequence include a visible button that closes the dialog, such as a close icon or a cancel button. Escape is not enough on its own. A touch-screen user has no Escape key.

Do I have to trap focus, and does that fail 2.1.2?

Trapping focus inside an open modal is correct, and it does not fail 2.1.2 No Keyboard Trap. The criterion says that if focus can be moved to a component, it must be possible to move it away using the keyboard, and if that needs something other than arrow or Tab keys the user has to be told (W3C). The W3C's guidance recognises that restricting focus to a subsection is sometimes appropriate, modal dialogs included, as long as the user can get out. Escape and a visible close button are the standard exits, so the trap is temporary and the user controls it.

The loop itself is mechanical: Tab from the last tabbable element goes to the first, Shift+Tab from the first goes to the last. showModal() gives you this. In a custom dialog you query the tabbable elements and wrap the ends by hand, and the thing that will bite you is a stale query. Query on Tab rather than caching the list when the dialog opens, otherwise a control revealed later by a checkbox inside the dialog is unreachable.

What can a scanner catch here, and what can't it?

Automated testing catches the static half. A dialog with no accessible name, an aria-modal container that is a descendant of an aria-hidden subtree, a tabindex on the dialog element: those are structural and a scanner will report them with the exact selector. Our scanner checks the rendered DOM against WCAG 2.2, and it will tell you a dialog has no accessible name in seconds.

It will not tell you whether focus returned to the right button. It cannot, because that is a sequence of states over time rather than a property of one snapshot. Deque's analysis of more than 2,000 audits put automated coverage near 57% of issues by volume, and closer to 30% measured as a share of success criteria a tool can evaluate at all (Deque). The W3C says it plainly: evaluation tools "can not determine accessibility, they can only assist in doing so" (W3C WAI). Full conformance needs a person testing the flow.

The manual check is short. Open the dialog with the keyboard only, Tab all the way round twice and confirm you never land on the page behind, press Escape, then check where focus went. If you cannot see where focus is, that is a finding on its own. Under WCAG 2.2 the same behaviours are what the EAA and EN 301 549 point at for the EU, and what US courts and agencies use as the benchmark under the ADA.

FAQ

Does the HTML dialog element need role="dialog"?

No. The native <dialog> element already provides the equivalent of role="dialog", and showModal() exposes aria-modal="true" on its own. Adding the ARIA back is redundant.

Should Escape always close a modal?

Yes, with one narrow exception. Escape is the expected exit and showModal() wires it up for you. The exception is a dialog whose dismissal would lose the user's work without asking, where you would use closedby="none" and provide explicit Save and Discard buttons instead.

When should I use role="alertdialog" instead of role="dialog"?

Use alertdialog for a modal that interrupts the workflow to communicate an important message and get a response, such as an error or a confirmation. The role lets assistive technologies treat it differently, for example by playing a system alert sound. It needs aria-describedby pointing at the message text (W3C ARIA APG).

Is a dimmed background enough to make a dialog modal?

No. Visual dimming without inertness is the failure the APG warns about. If the content behind is still reachable by Tab or exposed to a screen reader, do not set aria-modal="true" on the dialog, because that combination misleads assistive technology users about what is on the page.

Can a modal dialog fail Focus Not Obscured?

Rarely. Modal dialogs generally pass 2.4.11 because they take focus when they appear, so the focused component is inside the visible dialog. The criterion mostly catches non-modal overlays such as chat widgets and sticky footers that sit over focused content.

Check your dialogs against the rest of the page

Modals are the component most likely to be built once and copied everywhere, so a missing accessible name on your dialog wrapper usually shows up on every page that uses it. Run a free scan to collect the machine-detectable issues with their selectors, then take a keyboard through one dialog by hand and confirm focus goes in, stays in, and comes back to the button you started from. A scan will find the wrapper. Only the keyboard tells you whether anyone can get out of it.


Pavel Charkasau, founder, wcagc.com. Last updated 8 September 2026.

Sources