Notifications & Dialogs

Dialog (Modal)

Available

A dialog that renders in the foreground while making the background inert. Requires a focus trap and Escape key to close.

Specification updated

What Is a Modal Dialog?

A modal dialog is a small window that opens on top of the page andblocks interaction with the content behind it until it is closed. It is commonly used for settings panels, forms, and enlarged image views.

The hardest part of building a modal is focus management. Fortunately, using the native <dialog> element withshowModal() gives you all of that out of the box.

Why Does Accessibility Matter?

When a modal is not implemented correctly, these problems occur:

The native <dialog> + showModal() handles all of this automatically:focus trapping, Esc to close, making the backdrop inert, and restoring focus.

Live Demo (Recommended Implementation)

Press "Open settings" below to open the dialog. Try operating it with keyboard only — confirm that Tab doesn't escape the dialog and that Esc closes it.

Native <dialog> + showModal() modal

Display Settings

You can change the theme and font size here. (Demo content)

Try it: Press Enter to open → Tab repeatedly and focus stays inside the dialog → Esc to close → Focus returns to the 'Open settings' button.

Tip

The moment the dialog opens, focus moves inside it (to the first button or an element with autofocus). When it closes, focus automatically returns to the trigger, so users never lose their place in the page.

Keyboard Interaction

KeyActionPriority
EscClose the dialog (handled natively with showModal())Required
Tab / Shift + TabCycle focus within the dialog only (focus does not escape)Required
Enter / SpaceActivate the focused buttonRequired

Note

All of the keyboard behaviors above come for free when you use <dialog>.showModal(). With a homemade div modal, you would have to implement each one manually in JavaScript.

Required WAI-ARIA Roles and Properties

TargetAttribute / RoleMeaning
Dialog element<dialog> elementHas implicit role="dialog". Opened as a modal via showModal().
Dialog elementaria-labelledby="heading-id" or aria-labelGives the dialog an accessible name (typically referencing the heading inside).
Dialog elementaria-describedby (optional)Associates supplementary descriptive text.
Background content(automatic) inert equivalentshowModal() automatically makes the background inert. Apply inert manually if not using showModal().

Implementation: Recommended Pattern (Good)

Good / Recommended

Use the native <dialog> with showModal(). Focus management, Esc handling, and backdrop inertness come built in.

Markup:

<button type="button" id="open">Open settings</button>

<dialog id="dialog">
  <h2>Display Settings</h2>
  <p>You can change the theme and font size here.</p>
  <button type="button" id="close">Close</button>
</dialog>

Open/close script (that's all you need):

const dialog = document.getElementById('dialog');
const openBtn = document.getElementById('open');
const closeBtn = document.getElementById('close');

// showModal() alone gives you focus trapping, Esc to close,
// and making the backdrop inert — all built into the platform.
openBtn.addEventListener('click', () => dialog.showModal());
closeBtn.addEventListener('click', () => dialog.close());

// After closing, the browser automatically returns focus to the trigger.

Note

To give the dialog an accessible name, point <dialog aria-labelledby="title">to the id of the heading inside it. This way, a screen reader will announce "Display Settings, dialog" when it opens.

Anti-Pattern (Bad)

Below is a homemade modal built with a <div> overlay.You can open and close it with a mouse, but when you press Tabwhile it's open, focus escapes to the background. Esc won't close it either.

Broken modal using a div overlay

Try it: While the modal is open, press Tab repeatedly → focus moves to the 'Open settings' button and other elements behind the overlay. Esc does nothing. The × cannot be reached by keyboard.

<!-- ❌ A homemade modal using a div overlay -->
<button type="button" id="open">Open settings</button>

<div id="overlay" class="overlay" style="display:none">
  <div class="modal">
    <h2>Display Settings</h2>
    <p>You can change the theme and font size here.</p>
    <!-- span is not keyboard-accessible, so users can't close it -->
    <span class="x" onclick="hide()">×</span>
  </div>
</div>

Bad / Avoid

Problems with this implementation:

  • Tab escapes to the background — there is no focus trap, so focus moves to invisible elements behind the overlay.
  • Esc doesn't close it — no keyboard event handler is implemented.
  • Backdrop is not inert — screen readers can still read the content behind the modal.
  • Close button is a <span> — it cannot receive focus and is unreachable by keyboard.
  • No focus movement or restoration — opening doesn't move focus into the dialog, and closing doesn't return it to the trigger.

Tip

If you absolutely must use a <div>, you need to implement all of the following yourself: focus trapping, Esc handling, backdrop inert, focus restoration,role="dialog", and aria-modal="true". With <dialog>, most of that comes for free.

Implementation Checklist

Field Notes — Where practice diverges from the APG

Not trusting aria-modal="true" alone — hiding the background with aria-hidden / inert

The APG says
A modal dialog should have role="dialog" and aria-modal="true" — an ARIA 1.1 addition meant to replace the older technique of applying aria-hidden to all background elements.
What practice does
floating-ui's FloatingFocusManager and React Aria's ariaHideOutside walk the outside DOM tree while a modal is open and apply aria-hidden (or inert where possible). Base UI's Dialog / AlertDialog build on the same machinery; many such implementations never set aria-modal at all.
Why
It started with measured distrust of aria-modal support. A WebKit bug (reported 2017, unresolved for years) made VoiceOver skip static text inside aria-modal dialogs; Bootstrap and react-modal issues documented iOS VoiceOver swiping straight through focus traps into the background. aria-modal alone works in NVDA but not reliably in VoiceOver — so explicitly hiding the background with aria-hidden became the field standard that closes the gap across ATs, while fully meeting the APG intent: background hidden, focus contained, focus restored.

Adopted by:floating-uiReact AriaBase UI

Sources:WebKit Bug 174667 — aria-modal makes VoiceOver skip content(opens in a new tab)Floating UI — FloatingFocusManager(opens in a new tab)react-modal#611 — VoiceOver fails with aria-modal(opens in a new tab)

Native <dialog> went from "avoid" to "acceptable with conditions"

The APG says
The APG dialog pattern is written as a role="dialog" + JavaScript custom widget and does not take a position on the native <dialog> element.
What practice does
Scott O'Hara's "Having an open dialog" (2019) — long the canonical reason to avoid native dialog — was revised in January 2023 to a conditional endorsement. Meanwhile staple libraries like a11y-dialog still decline to adopt it, citing cross-browser styling differences and the missing open event.
Why
In 2019 only Chromium supported it, with measured problems: broken initial focus, inconsistent screen reader behavior, focus not returning on close. Once Safari 15.4 and Firefox 98 shipped support in 2022, the author updated to a conditional endorsement — keep robust custom implementations until user browser statistics catch up. A textbook case of recommendations changing on a measurable criterion (browser support reach) rather than opinion; that showModal() provides aria-modal semantics automatically also feeds the decision.

Adopted by:Scott O'Haraa11y-dialog

Sources:Scott O'Hara — Having an open dialog(opens in a new tab)a11y-dialog — On the dialog element(opens in a new tab)

Focus traps converged on invisible sentinel elements, not keydown interception

The APG says
The APG text specifies only the behavior — Tab wraps from last to first — and leaves the mechanism (sentinels vs. keydown handling) to the developer.
What practice does
The APG's own reference implementation (dialog.js) dynamically inserts tabindex="0" divs before and after the dialog and bounces focus back from them. Radix UI (react-focus-guards) inserts two hidden spans at the document edges; floating-ui's FloatingFocusManager generates the same hidden guards (enabled by default). The industry converged on the same mechanism independently.
Why
Intercepting Tab keydown alone cannot catch focus escaping by other means — mouse clicks, or a screen reader's virtual cursor. Focus-event-based sentinels catch departures regardless of how focus moved. This is convergence rather than conflict: on an implementation detail where the APG is silent, its reference code and the major libraries reached the same answer — and it is the mechanism to choose when hand-rolling a focus trap.

Adopted by:W3C APG reference implementationRadix UIfloating-ui

Sources:w3c/aria-practices#545 — Documenting the sentinel technique(opens in a new tab)W3C APG — Dialog (Modal) Pattern(opens in a new tab)Floating UI — FloatingFocusManager(guards)(opens in a new tab)


Source (English):Dialog (Modal) Pattern — W3C APG(opens in a new tab)