Accessible Dialog & Modal Guide
You almost certainly do not need a hand-rolled focus trap any more. This guide covers the native <dialog> element and showModal(), where initial focus really belongs, focus restoration, the inert top layer, alertdialog, scroll locking, and the ARIA fallback for when you cannot use the native element — with copy-ready code mapped to WCAG 2.2.
The Focus Trap You Were Told to Build Is Now Obsolete
For most of the last decade, building an accessible modal meant writing the same two hundred lines every time: query all the focusable elements inside the container, remember the first and the last, intercept every Tab and Shift+Tab to wrap focus around, hide the rest of the page from screen readers, bolt on an Escape handler, and remember which element to focus again on close. Every implementation got some part of it wrong, usually the part where a newly rendered button inside the dialog was never added to the focusable list.
That work is now done for you. The HTML <dialog> element, opened with showModal(), promotes itself into the browser 's top layer and makes every other element in the document inert. Content outside the dialog stops being focusable, clickable, and reachable by assistive technology. Tab cannot escape, because there is nowhere outside to go. Escape closes the dialog. Focus returns to the element that opened it. None of that is your code.
So this guide spends its time on the parts that are still your job, and that nearly every dialog on the web still gets wrong: deciding whether you need a modal at all, choosing where initial focus lands, naming the dialog, keeping the focused control visible at 400% zoom, and knowing when role="alertdialog" is warranted. It follows the WAI-ARIA Authoring Practices Modal Dialog pattern and maps every requirement to WCAG 2.2. It also covers the full ARIA fallback, because plenty of design systems still cannot adopt the native element overnight.
The one thing the browser cannot decide for you: where focus lands. Left to its own devices, the browser focuses the first focusable element in the dialog — which is almost always the close button in the corner. Your user hears “Close, button” and learns nothing. Set autofocus deliberately.
The WCAG 2.2 Criteria a Dialog Must Satisfy
| Criterion | Level | What the dialog must do |
|---|---|---|
| 1.3.1 Info & Relationships | A | The dialog is exposed as a dialog, with a heading that names it and a programmatic label pointing at that heading. |
| 2.1.1 Keyboard | A | Open, operate every control, and close — entirely from the keyboard, including the close affordance. |
| 2.1.2 No Keyboard Trap | A | Escape always dismisses the dialog. A modal confines focus deliberately, which is allowed only because there is a way out. |
| 2.4.3 Focus Order | A | Focus moves into the dialog on open and back to the triggering element on close, preserving the user's place. |
| 2.4.7 Focus Visible | AA | Every control inside the dialog has a clearly visible focus indicator against the dialog's own background. |
| 2.4.11 Focus Not Obscured | AA | The focused element is never hidden behind the dialog's sticky footer, its own header, or content the dialog overlays. |
| 1.4.10 Reflow | AA | At 320 CSS pixels wide and 400% zoom the dialog scrolls in one direction; buttons never fall outside the viewport. |
| 4.1.2 Name, Role, Value | A | The container exposes the dialog role and an accessible name; the modal state is conveyed to assistive technology. |
2.4.11 Focus Not Obscured is the criterion dialogs fail most often and audits catch least often, because it only shows up once the dialog body scrolls: a sticky action row at the bottom quietly covers the input you just tabbed to. Test it by tabbing through a long dialog at a short viewport height, not by reading the markup.
1. First, Decide: Modal, Non-Modal, or Not a Dialog?
A modal is the most disruptive component in your interface. It takes the whole page away from the user until they deal with it, which is occasionally exactly right and usually a design shortcut. Make this decision before you write markup, because it determines everything about the keyboard contract you are signing up for.
Modal dialog
The user must respond before continuing — confirm a deletion, complete a payment step, resolve a conflict. Everything else goes inert. Built with showModal(). This is what the rest of the guide covers.
Non-modal dialog
A floating panel the user can ignore while working — a find-in-page bar, a chat window. Built with show(). It gets no focus trap and no inert background, and it must never pretend to be modal.
Not a dialog at all
A dropdown of commands is a menu; expandable content is an accordion or disclosure; a whole form is usually just a page. None of these should take the page hostage.
One test settles it: if the user could reasonably want to read or copy something from the page behind your dialog while it is open, it should not be modal. Cookie banners, newsletter interstitials, and “are you sure you want to leave” prompts fail this test constantly — they block the content the user came for, and for someone using a screen reader or magnification the page has simply vanished.
2. Start With the Native <dialog> Element
There are three ways to show a <dialog>, and only one of them produces a modal. Getting this wrong is the single most common native-dialog bug, because all three look identical on screen once you have styled them.
| Method | What you get |
|---|---|
| showModal() | Top layer, inert background, ::backdrop, Escape-to-close, focus moved in on open and restored on close, exposed as a modal dialog. This is the one you want. |
| show() | A non-modal dialog. No top layer, no inert background, no backdrop, and no Escape handling. Correct for a floating panel, wrong for a modal. |
| the open attribute | Equivalent to show(). Toggling it by hand is explicitly discouraged, and it will never give you modal behaviour no matter how you style it. |
Here is a complete, conformant modal. Every accessibility-relevant decision in it is commented.
<button type="button" id="edit-trigger">Edit profile</button>
<!-- No role="dialog" and no aria-modal: the element and showModal() handle both. -->
<dialog id="edit-dialog" aria-labelledby="edit-dialog-title">
<form method="dialog">
<h2 id="edit-dialog-title">Edit your profile</h2>
<label for="display-name">Display name</label>
<!-- autofocus goes on the first meaningful control, NOT the close button. -->
<input id="display-name" name="displayName" type="text" autofocus>
<label for="pronouns">Pronouns</label>
<input id="pronouns" name="pronouns" type="text">
<div class="dialog-actions">
<!-- value is written to dialog.returnValue when the form closes the dialog. -->
<button value="cancel" formmethod="dialog">Cancel</button>
<button value="save" id="save-button">Save changes</button>
</div>
</form>
</dialog>const trigger = document.getElementById("edit-trigger")
const dialog = document.getElementById("edit-dialog")
const saveButton = document.getElementById("save-button")
// showModal() - not show(), not dialog.open = true.
trigger.addEventListener("click", () => dialog.showModal())
saveButton.addEventListener("click", (event) => {
event.preventDefault()
// ...validate and persist...
dialog.close("save") // close(value) sets returnValue.
})
// Fires on close(), on form method="dialog" submission, and on Escape.
dialog.addEventListener("close", () => {
if (dialog.returnValue === "save") {
// Announce the outcome in a live region - the dialog is gone,
// so anything it "said" on the way out is gone with it.
document.getElementById("status").textContent = "Profile updated."
}
// Focus restoration is automatic here. Only restore it manually
// if the trigger was removed or re-rendered while the dialog was open.
})
// The user pressed Escape (or a light-dismiss). Call
// event.preventDefault() here ONLY if you have unsaved changes to protect,
// and immediately give them another way out.
dialog.addEventListener("cancel", (event) => {
if (hasUnsavedChanges()) {
event.preventDefault()
showUnsavedChangesWarning()
}
})form method="dialog" is the tidiest close button you will ever write. A submit button inside such a form closes the dialog without submitting anything to a server, saves the state of the form controls, and sets returnValueto the button 's value — so a Cancel button needs no JavaScript at all, and you can tell afterwards how the dialog was dismissed.
3. Anatomy: Roles, States, and Properties
A native dialog needs far less ARIA than the tutorials suggest. The rule of thumb: the browser supplies the role and the modal state, and you supply the name and the focus intent.
| Element | Role | Key attributes |
|---|---|---|
| The dialog container | <dialog> (role="dialog" is implicit) | aria-labelledby pointing at the dialog's heading, or aria-label when there is no visible title. Do not add aria-modal by hand — showModal() sets the modal state for you. |
| The dialog heading | <h2> (or the level that fits the page) | A unique id that the container's aria-labelledby references. Every dialog needs a name; an unnamed dialog is announced as just "dialog". |
| An urgent dialog | role="alertdialog" | Overrides the implicit dialog role. Requires descendant message text referenced by aria-describedby and at least one focusable control. |
| The initial focus target | Any focusable element, or a heading with tabindex="-1" | autofocus, placed deliberately. Without it the browser falls back to the first focusable descendant, which is usually the wrong element. |
| The trigger button | <button> (no role needed) | A clear accessible name describing what opens. aria-haspopup="dialog" is optional and adds little; aria-expanded does not apply to modal dialogs. |
| Background content | No role change | inert, but only if you are not using showModal(). The native modal path makes the rest of the document inert automatically. |
The most consequential row is the name. A dialog without an accessible name is announced as, simply, “dialog” — the user is told the page has been taken over and not told why. Point aria-labelledby at the visible heading so the two can never drift apart. For how each role and state is exposed to assistive technology, see the ARIA roles & attributes reference.
4. Where Initial Focus Belongs (Not the Close Button)
When a dialog is shown, the browser runs the specification's dialog focusing steps: it looks for a descendant carrying the autofocusattribute and focuses that; failing that it focuses the dialog's focus delegate, which in practice is the first focusable descendant; and if the dialog contains nothing focusable at all, focus lands on the <dialog> element itself.
That fallback is why so many dialogs open by announcing “Close, button”. The sits first in the markup because it sits top-right on screen, so it wins the default. Decide for yourself instead. The Authoring Practices give four cases:
Mostly interactive
A short form or a couple of buttons. Focus the control the user came for — the first input, or the button that continues the workflow. Put autofocus on it.
Substantial reading content
Terms, a changelog, a long explanation. Focus a static element at the very top — the heading with tabIndex={-1} — so the text is not scrolled past before the user can read it.
Destructive action
“Delete this workspace?” Focus the least destructive option, so a reflexive Enter press cancels rather than destroys.
Simple confirmation
A single OK or Continue. Focus that button — it is both the most-used control and the way out, so it is the fastest landing spot for everyone.
<!-- Reading-heavy dialog: land on the heading so nothing scrolls past. -->
<dialog id="terms" aria-labelledby="terms-title">
<h2 id="terms-title" tabindex="-1" autofocus>Updated terms of service</h2>
<div class="dialog-body">
<p>...several screens of text...</p>
</div>
<form method="dialog">
<button value="decline">Decline</button>
<button value="accept">Accept</button>
</form>
</dialog>
<!-- Destructive dialog: land on Cancel, never on Delete. -->
<dialog id="confirm-delete" role="alertdialog"
aria-labelledby="delete-title" aria-describedby="delete-desc">
<h2 id="delete-title">Delete workspace?</h2>
<p id="delete-desc">
This permanently removes 42 projects and cannot be undone.
</p>
<form method="dialog">
<button value="cancel" autofocus>Cancel</button>
<button value="delete" class="danger">Delete workspace</button>
</form>
</dialog>tabindex="-1" makes an element programmatically focusable without adding it to the tab sequence, which is exactly what you want on a heading — you can send focus there, and Tab will never stop on it again. The focus management guide covers that technique and its siblings in depth.
5. Focus Restoration: The Half Everyone Forgets
Moving focus into a dialog is the half that gets implemented. Putting it back is the half that gets skipped, and skipping it is worse than it sounds: when focus is lost, most browsers reset it to the document body, so the user's next Tab press starts again from the top of the page. Someone who opened a dialog from a button deep in a long table is returned to the site header, with no explanation.
The native element handles this: a <dialog> remembers the previously focused element when it opens and refocuses it when it closes, whether the close came from close(), a method="dialog" form submission, or Escape. You get it free — but only while the trigger still exists.
The case you have to handle: the trigger is gone. A “Delete row” button opens a confirmation, the user confirms, and the row — including the button — is removed from the DOM. There is nothing left to restore focus to. Decide where focus should go next and send it there explicitly: the next row, the table itself, or a status message announcing what happened. The same applies whenever a framework re-renders the trigger into a new DOM node.
// Deleting the element that opened the dialog: choose the next
// focus target yourself, because the browser has nothing to restore to.
dialog.addEventListener("close", () => {
if (dialog.returnValue !== "delete") return
const row = document.getElementById(pendingRowId)
const nextRow = row.nextElementSibling ?? row.previousElementSibling
row.remove()
// Prefer a sibling; fall back to the container so focus is never lost.
const target = nextRow?.querySelector("button") ?? tableWrapper
target.focus()
// Removal is silent to a screen reader unless you say so.
status.textContent = "Row deleted."
})Note the live region in both examples. A dialog closing is not an announcement — whatever the dialog said disappears with it. If something happened as a result, put the outcome in an aria-live="polite" region that already exists on the page, satisfying 4.1.3 Status Messages.
6. The Keyboard Model
A dialog is a container, not a composite widget, so its keyboard contract is short. Tab and Shift+Tab move within it and wrap; Escape gets you out. Everything else belongs to the controls inside.
| Key | Expected behavior |
|---|---|
| Enter / Space (on the trigger) | Opens the dialog and moves focus into it, onto the element you marked with autofocus. |
| Tab | Moves to the next focusable element inside the dialog, then cycles back to the first. Content behind the dialog is never reached. |
| Shift + Tab | Moves to the previous focusable element, cycling to the last when on the first. |
| Escape | Closes the dialog and returns focus to the element that opened it. Required for every modal, with no exceptions. |
| Enter (on a default button) | Activates the primary action. Never wire Enter to a destructive action that has no confirmation. |
| Arrow keys | Belong to the widgets inside the dialog — a listbox, a radio group — not to the dialog itself. A dialog is not a composite widget. |
Escape is not negotiable. It is the one key every user of every assistive technology tries first, and without it a modal is a keyboard trap under 2.1.2. If you cancel the cancel event to protect unsaved work, you must replace the exit you just removed with a visible, keyboard-reachable one in the same breath. For the wider keyboard contract every custom component owes, see the keyboard accessibility guide.
7. When You Cannot Use <dialog>: The ARIA Fallback
Some design systems cannot adopt the native element yet — a custom animation pipeline, a positioning requirement the top layer fights, or simply a component with a thousand consumers. The fallback is well-defined, but you now own four things the browser was doing for you.
- Declare it.
role="dialog"plusaria-modal="true"on the container, witharia-labelledbynaming it. - Neutralise the background. Put the
inertattribute on every sibling of the dialog — not on a shared ancestor, or you will inert the dialog too.inertremoves the subtree from the tab order, from pointer events, and from the accessibility tree in one attribute. - Wrap Tab yourself. Compute the focusable elements each time the dialog opens — not once at startup — and wrap from last to first and back.
- Handle Escape and restoration. Store
document.activeElementbefore opening and refocus it on close.
const FOCUSABLE = [
"a[href]", "button:not([disabled])", "input:not([disabled])",
"select:not([disabled])", "textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",")
let previouslyFocused = null
function openDialog(dialog) {
previouslyFocused = document.activeElement
// inert every SIBLING of the dialog, never a shared ancestor.
for (const sibling of dialog.parentElement.children) {
if (sibling !== dialog) sibling.inert = true
}
dialog.hidden = false
// Recompute on every open: the contents may have changed since last time.
const focusables = dialog.querySelectorAll(FOCUSABLE)
const target = dialog.querySelector("[data-autofocus]") ?? focusables[0]
target?.focus()
dialog.addEventListener("keydown", onKeydown)
}
function onKeydown(event) {
const dialog = event.currentTarget
if (event.key === "Escape") { closeDialog(dialog); return }
if (event.key !== "Tab") return
const focusables = Array.from(dialog.querySelectorAll(FOCUSABLE))
if (focusables.length === 0) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
function closeDialog(dialog) {
dialog.hidden = true
dialog.removeEventListener("keydown", onKeydown)
for (const sibling of dialog.parentElement.children) sibling.inert = false
previouslyFocused?.focus() // The step people forget.
}Compare that to dialog.showModal()and the argument makes itself. Note too that the fallback's selector list is a permanent maintenance liability: it does not know about shadow DOM, about contenteditable, or about whatever becomes focusable next year. That is the class of bug the native element exists to delete.
8. Dialogs With No JavaScript At All
The Invoker Commands attributes command and commandfor let a button open and close a dialog declaratively, with no event listeners. They reached interoperable support across Chrome, Edge, Firefox, and Safari during 2025, which makes them a realistic progressive enhancement today rather than a future note.
<!-- Opens the dialog modally. No script involved. -->
<button type="button" command="show-modal" commandfor="prefs">
Preferences
</button>
<dialog id="prefs" aria-labelledby="prefs-title">
<h2 id="prefs-title">Preferences</h2>
<!-- ...settings... -->
<!-- "close" closes immediately; "request-close" fires the cancel
event first, so unsaved-changes logic still gets a say. -->
<button type="button" command="request-close" commandfor="prefs" autofocus>
Done
</button>
</dialog>Three commands apply to dialogs: show-modal, close, and request-close. Because the trigger is a real <button> and the target is a real <dialog>, the accessibility semantics are identical to the scripted version — you are removing the JavaScript, not the semantics. In a browser that has not shipped the API, the button simply does nothing, so keep a small listener that calls showModal() as a fallback until your support baseline catches up.
9. Backdrop, Scroll Locking, and Small Viewports
Three visual details decide whether the dialog is usable for people with low vision, and none of them are handled by the element for you.
/* The backdrop only exists for dialogs opened with showModal(). */
dialog::backdrop {
background: rgb(15 23 42 / 0.6);
}
dialog {
/* Never let the dialog exceed the viewport: at 400% zoom a
fixed height puts the action buttons out of reach (1.4.10). */
max-height: min(80vh, 40rem);
max-width: min(90vw, 32rem);
display: flex;
flex-direction: column;
padding: 0;
}
/* Scroll the BODY of the dialog, not the dialog itself, so the
heading and action row stay put and stay reachable. */
.dialog-body {
overflow-y: auto;
padding: 1.5rem;
/* Stops a sticky action row from covering the element you just
tabbed to - WCAG 2.4.11 Focus Not Obscured. */
scroll-padding-block-end: 4rem;
}
@media (prefers-reduced-motion: no-preference) {
dialog[open] { animation: dialog-in 150ms ease-out; }
}Scroll locking. Inertness governs focus and interaction, not scrolling — a wheel or trackpad gesture over the backdrop can still scroll the page underneath, which is disorienting when the content you are reading is meant to be frozen. Set overflow: hidden on the document element while the dialog is open and restore the previous value on close. On iOS, watch for the scroll position jumping to the top; capture and restore it if you see that. Never remove scrolling from inside the dialog — a long dialog must scroll, or its content becomes unreachable at high zoom.
Backdrop contrast. The backdrop dims the page, but the dialog's own text still has to meet 1.4.3 Contrast (Minimum) against the dialog surface — not against the dimmed page behind it. A translucent dialog panel over a busy backdrop is a reliable way to fail. Check the real rendered colours with the contrast checker.
10. alertdialog: For Urgent Messages Only
role="alertdialog" is a dialog that also behaves like an alert: assistive technology announces its message on open rather than waiting for the user to explore. Use it for interruptions that demand an immediate decision — a destructive confirmation, a session about to expire, a payment that failed mid-checkout.
It carries two obligations. The message must be descendant text referenced by aria-describedby, so there is something to announce, and the dialog must contain at least one focusable control, so the user can act on it. You can put the role directly on a native <dialog>; it overrides the implicit dialog role while leaving the modal behaviour of showModal() intact.
Use alertdialog
“Delete 42 projects permanently?” • “Your session expires in 60 seconds.” • “Payment declined — your order was not placed.” Something has gone wrong, or is about to, and the user must decide now.
Use a plain dialog
Settings panels, sign-in forms, image lightboxes, filter sheets, “share this” menus. The user opened it on purpose and nothing is at stake. Overusing the urgent role teaches people to ignore it.
A related trap: a timeout warning is not just an alertdialog problem. If a session can expire while the user is reading, you also owe them 2.2.1 Timing Adjustable — a way to extend the time, announced early enough to act on. Someone using a screen reader to complete a form may need several times the median session.
11. Dialogs in React
React's instinct is to render the dialog conditionally and let state drive everything. That fights the native element, because showModal()is an imperative call, not a prop. Keep the element mounted and drive it through a ref in an effect — this way the browser's focus and inertness machinery still runs.
import { useEffect, useId, useRef } from "react"
function ConfirmDialog({ open, onClose, onConfirm, count }) {
const ref = useRef(null)
const titleId = useId()
const descId = useId()
useEffect(() => {
const dialog = ref.current
if (!dialog) return
// Guard on dialog.open: calling showModal() on an open dialog throws.
if (open && !dialog.open) dialog.showModal()
if (!open && dialog.open) dialog.close()
}, [open])
return (
// Always rendered - never conditionally mounted, or the browser
// has no element to move focus into and nothing to restore from.
<dialog
ref={ref}
role="alertdialog"
aria-labelledby={titleId}
aria-describedby={descId}
// Fires for Escape and light-dismiss as well as close():
// keep React state in sync or the dialog cannot reopen.
onClose={onClose}
>
<h2 id={titleId}>Delete workspace?</h2>
<p id={descId}>
This permanently removes {count} projects and cannot be undone.
</p>
<div className="dialog-actions">
{/* autofocus on the safe choice, not the destructive one. */}
<button type="button" autoFocus onClick={onClose}>
Cancel
</button>
<button type="button" className="danger" onClick={onConfirm}>
Delete workspace
</button>
</div>
</dialog>
)
}Two failure modes are worth naming. Conditional mounting — {open && <dialog>} — destroys the element on close, so there is nothing left to restore focus from, and the user is dumped on <body>. And forgetting onClose lets Escape close the dialog visually while your open state still says true, so the next click on the trigger appears to do nothing.
For production, headless libraries remain a reasonable choice — Radix UI's Dialog, React Aria's useDialog, and Headless UI's Dialog all implement the pattern and handle the edge cases across browsers. The same guidance applies in other frameworks: see the Vue and Angular guides, and the React accessibility guide for the surrounding patterns. Whatever you ship, verify it against the testing workflow below rather than trusting the README.
How to Test an Accessible Dialog
Automated tools catch a missing accessible name and a broken aria-labelledby reference. Everything that decides whether the dialog is actually usable — where focus goes, whether you can get out, whether the buttons are reachable at zoom — takes six minutes by hand.
- Open it from the keyboard. Tab to the trigger, press Enter, and note where focus lands. If the answer is “the close button”, fix it before going further.
- Tab all the way round. Keep pressing Tab past the last control. Focus must cycle back into the dialog and must never reach a link or button on the page behind it.
- Press Escape. The dialog closes and focus is back on the exact element that opened it — not
<body>, not the top of the page. - Delete the trigger. If the dialog's action removes its own trigger, run that path and confirm focus lands somewhere deliberate rather than being lost.
- Zoom to 400% and narrow to 320px. The dialog body scrolls, the action buttons stay reachable, and the focused field is never hidden behind a sticky footer (2.4.11).
- Listen with a screen reader. You should hear the dialog's name on open, then the focused element. Try to read the page behind it — you should not be able to. The screen reader testing guide has the commands.
- Check the outcome is announced. Complete the dialog's action and confirm the result reaches a live region. A dialog that closes silently after saving tells a screen reader user nothing.
Layer automated checks on top with axe-core, and see automated vs manual testing for where each fits. Scan the live page with the URL accessibility auditor, and try the interactive modal dialog demo to feel the difference between a correct and a broken implementation.
Common Dialog Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| A <div> with a class of .modal and no role or accessible name. | Assistive technology has no idea a dialog opened; the content is announced as ordinary page content wherever it happens to sit in the DOM (1.3.1, 4.1.2). | Use <dialog> with showModal(), or role="dialog" plus aria-modal="true" and an accessible name. |
| Focus stays on the trigger button, or jumps to the top of the page, when the dialog opens. | A keyboard user has to Tab blindly through the whole page to reach the dialog, and a screen reader user is never told it appeared (2.4.3). | Move focus into the dialog on open, onto the element marked with autofocus. |
| Focus is dumped on <body> when the dialog closes. | The user loses their place entirely and restarts from the top of the document on the next Tab press (2.4.3). | Restore focus to the exact element that opened the dialog. The native element does this for you. |
| Escape does nothing, or closes the dialog only when a specific element has focus. | The keyboard user has no reliable, consistent way out of the dialog (2.1.2). | Escape closes any modal from anywhere inside it. showModal() gives you this behaviour free. |
| Tab reaches links and buttons on the page behind the dialog. | The user operates invisible controls they cannot see, and the modal promise is broken (2.4.3, 2.1.1). | Use showModal(), or apply inert to every sibling of the dialog and manage Tab wrapping yourself. |
| autofocus is on the ✕ close button. | The first thing announced is "Close, button", which tells the user nothing about why the dialog appeared (2.4.3). | Put autofocus on the first field, the primary action, or the heading with tabindex="-1". |
| The dialog is fixed-height with the buttons pinned below the fold. | At 400% zoom or on a short viewport the confirm button is unreachable and the focused element is obscured (1.4.10, 2.4.11). | Cap the dialog with max-height and let its body scroll; keep the action row inside the scrollable box. |
| A dialog that opens automatically on page load and cannot be dismissed by keyboard. | It becomes a keyboard trap on the very first interaction with the page (2.1.2). | Give every dialog a keyboard-reachable close control and Escape handling, whatever triggered it. |
Accessible Dialog Checklist
- Right pattern. It genuinely needs to block the page. If the user could want the content behind it, it is not a modal.
- Native element.
<dialog>opened withshowModal()— nevershow(), never theopenattribute, for a modal. - It has a name.
aria-labelledbypoints at the visible heading, oraria-labelwhen there is no title. - Focus lands deliberately.
autofocusis on the first meaningful control, the heading, or the least destructive button — never the ✕. - Focus comes back. The trigger regains focus on close, and you handle the case where the trigger no longer exists.
- Escape works everywhere. From any element inside the dialog, satisfying 2.1.2.
- Nothing behind is reachable. Tab never escapes to the page; background content is inert, not merely covered.
- It survives zoom. The body scrolls, buttons stay reachable at 400%, and the focused element is never obscured (2.4.11).
- The outcome is announced. Whatever the dialog did is reported in a live region after it closes.
Work through the full WCAG 2.2 checklist to see the dialog in the context of every other requirement.
Check Your Dialogs on a Live Page
Scan any page with our free axe-core-powered auditor to catch an unnamed dialog, a broken aria-labelledby reference, or insufficient contrast inside the panel — then run the seven-step keyboard pass above for the failures no scanner can see.
Frequently Asked Questions
Do I still need a focus trap for a modal dialog?▾
Not if you use the native <dialog> element with showModal(). Calling showModal() promotes the dialog into the browser's top layer and makes every other element in the document inert, which means content outside the dialog is not focusable, not clickable, and not reachable by assistive technology. Tab therefore cannot escape, because there is nothing outside to move to — you get the effect of a focus trap without writing one. Focus may still cycle out to browser UI such as the address bar, which is correct and expected behaviour that no dialog should prevent. You only need to build a trap yourself if you cannot use the native element, in which case you must combine role="dialog", aria-modal="true", the inert attribute on the background content, and your own Tab and Shift+Tab wrapping logic.
Where should focus go when a dialog opens?▾
Onto the first thing the user needs, which is rarely the close button. The WAI-ARIA Authoring Practices give four cases. If the dialog is mostly interactive with few controls, focus the element the user is most likely to want, such as the first form field or the primary confirm button. If the dialog contains substantial reading content, focus a static element at the top — the heading with tabindex="-1" — so the content is not scrolled past. If the action is destructive, focus the least destructive option so an accidental Enter press cannot delete anything. And if the dialog is a simple confirmation, focus the button that continues the workflow. In the native <dialog> element, express your choice with the autofocus attribute rather than leaving it to the browser's default.
What happens if I do not set autofocus on a native dialog?▾
The browser follows the specification's dialog focusing steps: it looks for a descendant with the autofocus attribute, and if there is none it falls back to the dialog's focus delegate, which is effectively the first focusable descendant; if the dialog has no focusable descendant at all, focus lands on the <dialog> element itself. In practice the first focusable descendant is very often the ✕ close button in the corner, because that is where it sits in the markup. That is a poor landing spot — a screen reader user hears "Close, button" and learns nothing about what the dialog is for. Always set autofocus deliberately on the element that represents the dialog's purpose.
Should I add role="dialog" and aria-modal="true" to a native <dialog> element?▾
No. A native <dialog> is already exposed with the dialog role, and browsers set the modal state for you: a dialog opened with showModal() is exposed as modal, while one opened with show() or the open attribute is exposed as non-modal. Adding the attributes by hand is redundant and, in the case of aria-modal, actively risky — it fights with the state the browser is already computing. You do still need to give the dialog an accessible name with aria-labelledby pointing at its heading, or aria-label if there is no visible title. The one role you may legitimately add is role="alertdialog", which overrides the implicit dialog role for urgent messages that require a response.
When should I use role="alertdialog" instead of a plain dialog?▾
Use alertdialog when the dialog interrupts the user with an urgent message that needs an immediate response — a confirmation before deleting data, a session-expiry warning, a failed-payment notice. The role tells assistive technology to treat the contents as an alert, so screen readers announce the message text on open rather than waiting for the user to explore. It carries a requirement: an alertdialog must contain the message as descendant text, referenced by aria-describedby, and it must contain at least one focusable control. Do not use it for ordinary dialogs such as a settings panel or a sign-in form; the extra urgency is noise, and overusing it trains users to ignore it.
Can I open a modal dialog without any JavaScript?▾
Yes, using the Invoker Commands attributes command and commandfor on a button. A button with commandfor="my-dialog" and command="show-modal" opens that dialog modally, and a button inside with command="close" closes it — no event listeners, no script. The commands available for dialogs are show-modal, close, and request-close. This reached interoperable support across Chrome, Edge, Firefox, and Safari during 2025, so treat it as a progressive enhancement for now: ship the declarative markup, and keep a small JavaScript fallback that calls showModal() for older browsers. Accessibility is unaffected either way, because the button is a real button and the dialog is a real dialog.
Why does the page behind my modal still scroll?▾
Because inertness is about focus and interaction, not scrolling. showModal() prevents the background from being focused or clicked, but a trackpad or mouse wheel over the backdrop can still scroll the document underneath in some browsers, which is disorienting and can push the dialog's own content out of view. The usual fix is to set overflow: hidden on the document element while the dialog is open and restore it on close. Be careful on iOS, where changing overflow on the body can jump the scroll position; capture and restore scrollTop if you see that. Never solve it by disabling scrolling inside the dialog itself — a long dialog must remain scrollable, or its content becomes unreachable at small viewport sizes and high zoom.
How do I test a dialog for accessibility?▾
Six minutes of keyboard and screen reader work catches almost everything automated tools miss. Open the dialog from the keyboard and note where focus lands and what is announced. Press Tab repeatedly and confirm you never reach anything behind the dialog. Press Escape and confirm the dialog closes and focus returns to the exact button that opened it. Resize to a narrow viewport and zoom to 400% to confirm the dialog scrolls rather than clipping its buttons. Check that the focused element inside the dialog is never hidden behind a sticky header or the dialog's own footer, which is WCAG 2.4.11. Then run an automated scan for the mechanical failures — a missing accessible name, a broken aria-labelledby reference, or insufficient contrast on the backdrop overlay.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences