Modal dialogs are one of the components I most often find broken in audits. Focus stays behind the modal, so keyboard users tab through a page they can’t see. Screen reader users never hear that a dialog opened. Escape does nothing. And when the dialog closes, focus jumps to the top of the page.
For years, building an accessible modal meant writing a lot of JavaScript to trap focus and hide the background. Today, the native HTML dialog element does most of that work for you. This guide shows a tested pattern, explains why each part matters, and gives you a checklist to test it.
Key takeaways
- Use the native
dialogelement and open it withshowModal(). It makes the rest of the page inert and handles Escape. - Give the dialog an accessible name with
aria-labelledbypointing at its heading. - Move focus into the dialog when it opens, and return it to the triggering button when it closes.
- Provide a visible close button, and make sure the dialog content is reachable at 400% zoom.
- Before using a modal at all, ask whether the content could live on the page instead.
What makes a modal accessible?
Users of assistive technology need a modal to behave like this:
- It’s announced when it opens, with its name (“Delete project, dialog”).
- Focus moves into it, so the next keystroke acts inside the dialog.
- The background is inert: Tab can’t reach it, and screen readers don’t read it.
- It can be closed with a visible button and with the Escape key.
- Focus returns to where the user was when it closes.
These map to several WCAG criteria, including 2.1.1 Keyboard, 2.1.2 No Keyboard Trap, 2.4.3 Focus Order, 1.3.1 Info and Relationships and 4.1.2 Name, Role, Value.
The pattern
HTML
<button type="button" id="open-delete" aria-haspopup="dialog">
Delete project
</button>
<dialog id="delete-dialog" aria-labelledby="delete-title" aria-describedby="delete-desc">
<h2 id="delete-title">Delete this project?</h2>
<p id="delete-desc">
This will permanently delete the project and its files. You can't undo this.
</p>
<div class="dialog-actions">
<button type="button" id="cancel-delete" autofocus>Cancel</button>
<button type="button" id="confirm-delete">Delete project</button>
</div>
<button type="button" class="dialog-close" aria-label="Close">×</button>
</dialog>JavaScript
const openBtn = document.getElementById('open-delete');
const dialog = document.getElementById('delete-dialog');
const cancelBtn = document.getElementById('cancel-delete');
const closeBtn = dialog.querySelector('.dialog-close');
let lastFocused = null;
openBtn.addEventListener('click', () => {
lastFocused = document.activeElement;
dialog.showModal();
});
function closeDialog() {
dialog.close();
}
cancelBtn.addEventListener('click', closeDialog);
closeBtn.addEventListener('click', closeDialog);
// Runs for Escape, the close buttons, and any other way the dialog closes
dialog.addEventListener('close', () => {
if (lastFocused) lastFocused.focus();
});CSS
dialog {
max-width: min(32rem, 90vw);
max-height: 90vh;
overflow: auto;
border: 2px solid #1f2a8a;
border-radius: 12px;
padding: 1.5rem;
}
dialog::backdrop {
background: rgb(0 0 0 / 0.6);
}
dialog :focus-visible {
outline: 3px solid #1f2a8a;
outline-offset: 3px;
}Why each part matters
showModal(), not show()
showModal() opens the dialog in the top layer and makes everything outside it inert: keyboard focus and screen reader virtual cursors can’t reach the background. It also makes Escape close the dialog. show() opens a non-modal dialog with none of that behaviour.
aria-labelledby and aria-describedby
The dialog element has the dialog role built in, but it still needs a name. Pointing aria-labelledby at the heading means screen readers announce “Delete this project?, dialog”. aria-describedby adds the warning text, which is valuable for confirmation dialogs.
Where focus goes on open
By default, showModal() focuses the first focusable element in the dialog. You can control this with autofocus. For destructive actions, focusing the safe option (Cancel) prevents an accidental Enter from deleting something. For long or content-heavy dialogs, consider focusing the heading instead (give it tabindex="-1") so screen reader users start reading from the top.
Returning focus on close
When the dialog closes, keyboard and screen reader users should land back on the button that opened it, so they can continue where they left off. Modern browsers restore focus automatically in many cases, but handling it explicitly in the close event, as above, makes the behaviour reliable. If the trigger no longer exists (for example, the item was deleted), move focus to a sensible nearby element, such as the list heading.
A visible close button
Not everyone knows about Escape, and many touch-screen and switch users can’t press it. Always provide a visible close button with an accessible name. If you use an icon such as “×”, give the button aria-label="Close" or include visually hidden text.
Common modal mistakes
- Using a
divwithrole="dialog"and no focus management. Focus stays behind the modal. - No accessible name. Screen readers announce “dialog” with no context.
- Closing on any click outside without an alternative. Fine as an extra, but keyboard and screen reader users need a button.
- Content that can’t scroll. At high zoom levels, the dialog extends off screen and the buttons become unreachable. Set a
max-heightandoverflow: auto. - Modals that open automatically on page load, such as newsletter popups. They interrupt everyone and are a frequent source of keyboard traps.
- Nesting modals. A dialog that opens another dialog is confusing for everyone. Rethink the flow.
Should it be a modal at all?
Modals interrupt. They’re appropriate for things that genuinely need the user’s attention before continuing: confirming a destructive action, a short focused task, or a critical warning. For long forms, content the user may want to compare with the page, or anything that needs scrolling, an inline section or a separate page is usually more accessible and more usable.
How to test your modal
Keyboard
- Tab to the trigger and press Enter. Does focus move into the dialog?
- Press Tab repeatedly. Does focus stay inside the dialog and its controls, never reaching the page behind it?
- Press Escape. Does the dialog close and focus return to the trigger?
- Reopen and close with the close button and the Cancel button. Same result?
Screen reader (see my screen reader testing guide)
- When the dialog opens, do you hear its name and role?
- Using the virtual cursor (arrow keys in NVDA, VO + arrows in VoiceOver), can you read the background? You shouldn’t be able to.
- Are all buttons announced with clear names?
Visual
- Is the focus indicator visible on every control?
- At 400% zoom, can you reach every part of the dialog?
- Does the backdrop clearly separate the dialog from the page, with sufficient contrast?
Conclusion
The native dialog element has made accessible modals far easier than they used to be. Open it with showModal(), label it, control where focus goes on open and close, provide a visible close button, and make sure it works at high zoom. Then test it with a keyboard and a screen reader, because a modal that fails is one of the fastest ways to block a user completely.
Further reading
- Keyboard Accessibility and Focus Management
- The First Rule of ARIA: 10 Common ARIA Mistakes
- MDN: The Dialog element
- W3C APG: Dialog (Modal) Pattern
