Accessible Modals: How to Build Dialogs the Right Way
Accessible modal dialogs: fix focus traps, keyboard escape, screen reader announcements, and the WCAG violations that break 70% of modals in the wild.
Like accessible custom dropdowns, modals are everywhere. Cookie consent, login forms, confirmation dialogs, image lightboxes, newsletter signups, settings panels. The average user hits a modal within seconds of landing on most commercial websites. And most of those modals are broken for anyone not using a mouse.
We scanned over 15,000 pages through AccessGuard last quarter. Modal dialogs appeared on roughly 40 percent of them. Of the pages with modals, over 70 percent failed at least one WCAG criterion directly because of the modal itself, not the page behind it. The three most common failures were missing focus management, no keyboard escape route, and screen readers not knowing the modal existed.
This post covers every accessibility requirement for modal dialogs, what the relevant WCAG criteria actually say, and the exact code patterns that satisfy each one. If you build modals, copy-paste from here and you will be ahead of 90 percent of the web.
Why modals are uniquely dangerous for accessibility
A modal dialog does something no other UI pattern does: it demands exclusive attention. It sits on top of everything, it (should) prevent interaction with the page behind it, and it expects the user to deal with it before doing anything else.
For sighted mouse users, that contract is visually obvious. The backdrop dims, the box appears in the center, you click inside it or click the X to close it. Done.
For everyone else, the contract is invisible unless you build it explicitly. A screen reader user has no idea a modal just appeared unless you tell their assistive technology. A keyboard user cannot "see" the backdrop and will tab straight out of the modal into the page behind it unless you trap focus. A voice control user cannot say "click close" unless the close button has an accessible name.
The result is that a modal either works perfectly or fails catastrophically. There is no graceful degradation. A half-accessible modal is arguably worse than a completely inaccessible one, because it lets users get halfway in before stranding them.
The WCAG criteria that apply to modals
Modals touch a surprisingly large number of WCAG success criteria. Here are the ones that matter most, in the order you are most likely to fail them.
4.1.2 Name, Role, Value (Level A). The modal must have role="dialog" or role="alertdialog", and it must have an accessible name via aria-labelledby or aria-label. Without these, screen readers do not announce it as a dialog. They treat it as a random pile of HTML that appeared on the page.
2.4.3 Focus Order (Level A). When the modal opens, focus must move into it. When it closes, focus must return to the element that triggered it. If focus stays on the page behind the modal, keyboard users are interacting with content they cannot see.
2.1.2 No Keyboard Trap (Level A). The user must be able to leave the modal using the keyboard. In practice, this means the Escape key must close the modal. It also means that if your modal has no focusable elements, the user is trapped, because they cannot Tab to anything and Escape does nothing if you did not wire it up.
2.1.1 Keyboard (Level A). Every interactive element inside the modal must be operable with a keyboard. This sounds obvious, but custom close buttons built from div elements with only an onclick handler fail this completely.
1.3.1 Info and Relationships (Level A). The modal's heading, form labels, and structure must be conveyed programmatically. A modal with a visual heading that is just a styled span is missing its landmark for screen reader users.
2.4.7 Focus Visible (Level AA). Focus indicators must be visible inside the modal, just like everywhere else. Many modal libraries apply outline: none to their containers for cosmetic reasons, which kills focus visibility for every element inside.
1.4.3 Contrast (Level AA). The modal's text, buttons, and links need to meet the same 4.5:1 contrast ratios as the rest of the page. The semi-transparent backdrop behind the modal does not count as the background color for contrast calculations. The modal's own background does.
Use the native dialog element
The single biggest improvement you can make is to use the HTML <dialog> element instead of building a modal from divs. The <dialog> element, when opened with its .showModal() method, gives you several things for free that would otherwise take dozens of lines of JavaScript to replicate.
It creates a top layer that sits above everything else in the document, no z-index wars. It renders an inert backdrop via the ::backdrop pseudo-element. It traps focus inside the dialog automatically. It closes on Escape by default. And it returns focus to the previously focused element when closed.
Here is the minimal accessible pattern:
<button id="open-btn" onclick="document.getElementById('my-dialog').showModal()">
Open settings
</button>
<dialog id="my-dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Settings</h2>
<p>Adjust your notification preferences below.</p>
<form method="dialog">
<label for="email-notifications">
<input type="checkbox" id="email-notifications" checked>
Email notifications
</label>
<button type="submit">Save</button>
</form>
</dialog>
That is it. No ARIA role needed because <dialog> has an implicit role of dialog. No JavaScript focus trap because .showModal() handles it. No Escape key handler because the browser does it. No return-focus logic because the browser does that too. The method="dialog" on the form means submitting it closes the dialog, which is the correct behavior for settings panels and confirmation dialogs.
Browser support for <dialog> is excellent: Chrome, Edge, Firefox, and Safari all support it fully. There is no reason to avoid it in 2026.
If you cannot use dialog: the manual checklist
Sometimes you are working with a legacy codebase, a component library that predates native dialog support, or a framework that generates its own modal markup. In those cases, you need to build every feature by hand. Here is the complete list.
1. Add role="dialog" and an accessible name.
<div role="dialog" aria-labelledby="modal-heading" aria-modal="true">
<h2 id="modal-heading">Confirm deletion</h2>
<!-- modal content -->
</div>
The aria-modal="true" attribute tells assistive technology that the rest of the page is inert. This is the ARIA equivalent of what .showModal() does natively. Without it, screen readers may let users navigate to content behind the modal using virtual cursor commands, even if you have a visual backdrop.
2. Move focus into the modal on open. Focus should land on the first focusable element inside the modal, or on the modal container itself if it has tabindex="-1". Never leave focus on the trigger button behind the backdrop.
function openModal(modal) {
modal.style.display = 'flex';
const firstFocusable = modal.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (firstFocusable) {
firstFocusable.focus();
} else {
modal.setAttribute('tabindex', '-1');
modal.focus();
}
}
3. Trap focus inside the modal. Tab and Shift+Tab must cycle through the modal's focusable elements without ever leaving. When the user tabs past the last element, focus wraps to the first. When they shift-tab before the first, focus wraps to the last.
function trapFocus(modal, event) {
const focusables = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.key === 'Tab') {
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
4. Close on Escape. Every modal must close when the user presses the Escape key. No exceptions.
modal.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
closeModal(modal);
}
});
5. Return focus on close. When the modal closes, focus must return to the element that opened it. If that element no longer exists (it was removed from the DOM while the modal was open), focus should go to a sensible fallback, like the main content area.
let triggerElement = null;
function openModal(modal, trigger) {
triggerElement = trigger;
modal.style.display = 'flex';
// ... focus first element
}
function closeModal(modal) {
modal.style.display = 'none';
if (triggerElement && document.body.contains(triggerElement)) {
triggerElement.focus();
} else {
document.querySelector('main')?.focus();
}
}
6. Make the backdrop inert. While the modal is open, users should not be able to interact with anything behind it. For screen readers, aria-modal="true" handles this. For keyboard users, the focus trap handles it. For mouse users, the backdrop click handler handles it. But for completeness, consider adding the inert attribute to the rest of the page content:
document.querySelector('main').setAttribute('inert', '');
// Remove it when the modal closes
document.querySelector('main').removeAttribute('inert');
The inert attribute makes an element and all its descendants unfocusable and invisible to assistive technology. It is the nuclear option and it works. Browser support is universal as of 2024.
The close button problem
Almost every modal has an X button in the top-right corner. Almost every one of them is broken. Here is why, and how to fix it.
The most common pattern we see in the wild:
<!-- broken -->
<div class="close-btn" onclick="closeModal()">×</div>
This fails three criteria at once. It is not a button, so it has no implicit role and is not keyboard focusable (fails 2.1.1). It has no accessible name because the multiplication sign character is not meaningful text (fails 4.1.2). And because it is not focusable, it cannot receive a visible focus indicator (fails 2.4.7).
The fix:
<!-- fixed -->
<button type="button" aria-label="Close" onclick="closeModal()">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
Use a real <button>. Give it aria-label="Close" so screen readers announce "Close, button." Hide the decorative SVG icon from assistive technology with aria-hidden="true". Done.
Alert dialogs: when the modal demands a decision
If your modal requires the user to acknowledge something or make a choice before proceeding (confirm deletion, accept terms, session timeout warning), use role="alertdialog" instead of role="dialog".
<dialog role="alertdialog" aria-labelledby="alert-title" aria-describedby="alert-desc">
<h2 id="alert-title">Delete this project?</h2>
<p id="alert-desc">This will permanently remove the project and all its data. This action cannot be undone.</p>
<button onclick="confirmDelete()">Delete permanently</button>
<button onclick="this.closest('dialog').close()">Cancel</button>
</dialog>
The difference is that alertdialog tells screen readers this is urgent. Most screen readers will interrupt whatever they were reading to announce it. Use this for destructive actions, timeouts, and errors. Do not use it for settings panels, forms, or informational content, that is what regular dialog is for.
Notice the aria-describedby pointing to the explanatory paragraph. This gives screen readers the full context: "Delete this project? dialog. This will permanently remove the project and all its data." Without it, they only hear the title, which may not convey the severity.
Common modal frameworks and what they get wrong
We see five modal patterns over and over in our scans. Here is where each one typically fails and what to fix.
Bootstrap modals get the basics mostly right: role="dialog", aria-labelledby, and focus management are all built in. The typical failure is that developers override the keyboard handling or forget to add aria-label to the close button. If you are using Bootstrap, check that your close button has aria-label="Close" and that you have not disabled keyboard events on the modal.
Custom React modals (the "I built it myself" pattern) are the worst offenders. The most common version is a div with a high z-index, an onClick handler to close on backdrop click, and absolutely nothing else. No role, no aria-label, no focus trap, no Escape handler. If this is you, use Radix Dialog or Headless UI Dialog instead. Both are unstyled, fully accessible, and handle every edge case described in this post.
SweetAlert and SweetAlert2 generate their own modals with good ARIA markup but inconsistent focus management. Focus sometimes lands on the confirm button, sometimes on the modal container, and sometimes nowhere. Test each variant you use.
jQuery UI Dialog was actually ahead of its time on accessibility. It manages focus, traps Tab, closes on Escape, and returns focus on close. The main issue is that it sets role="dialog" but relies on the title option for the accessible name, so if you skip the title, the dialog is unnamed.
Tailwind UI / Headless UI gets it right. If you are using Headless UI's Dialog component, you get the native dialog behavior with full focus management, Escape handling, and screen reader support. The only thing you can still break is forgetting to pass a meaningful title to Dialog.Title.
Testing your modals
You can test modal accessibility in under five minutes with nothing but your keyboard and a screen reader. Here is the exact process.
Keyboard test (two minutes). Open the modal using only the keyboard (Tab to the trigger, press Enter). Verify that focus moves into the modal. Press Tab repeatedly and confirm focus cycles through all interactive elements inside the modal and never leaves it. Press Shift+Tab from the first element and confirm focus wraps to the last element. Press Escape and confirm the modal closes. Confirm focus returns to the trigger button.
Screen reader test (three minutes). Turn on VoiceOver (Cmd+F5 on Mac) or NVDA (free on Windows). Open the modal. Listen for the screen reader to announce the dialog role and its name (e.g., "Settings, dialog"). Navigate through the content and confirm everything is announced. Close the modal and confirm the screen reader announces the return to the trigger button.
If either test fails at any step, you have a WCAG violation.
What AccessGuard checks automatically
Our scanner detects all of the following on every page it scans, without any configuration:
Elements with role="dialog" or role="alertdialog" that are missing an accessible name via aria-labelledby or aria-label. Close buttons inside modals that are not real buttons or lack accessible names. Modals that are visible in the DOM but do not have aria-modal="true" (unless they use the native <dialog> element, which handles this implicitly). Interactive elements inside modals that are not keyboard focusable. Focus indicators that are suppressed inside modal containers.
We do not (yet) test focus management behavior like trapping and return-focus, since that requires opening and closing the modal at runtime. That is on the roadmap.
The one-paragraph summary
Use the native <dialog> element with .showModal(). Give it an accessible name with aria-labelledby. Make sure the close button is a real <button> with aria-label="Close". If you cannot use native dialog, add role="dialog", aria-modal="true", trap focus manually, close on Escape, and return focus on close. Test it with your keyboard. Test it with a screen reader. That is the whole thing. Every modal on the web should take fifteen minutes to get right, and most of them have never had those fifteen minutes.