A modal dialog interrupts the page to ask for a decision or show focused content. Built by hand, modals are one of the most error-prone components on the web: focus escapes behind them, screen readers read the page underneath, and keyboard users cannot close them. The native dialog element solves most of this for you.
What an accessible modal must do
The ARIA Authoring Practices Guide (APG) Dialog (Modal) pattern sets out the expected behaviour:
- Focus moves into the dialog when it opens, to the first sensible element.
- Focus stays inside while it is open.
TabandShift+Tabcycle through its controls only. - The content behind is inert: it cannot be focused, clicked or read by screen readers.
Escapecloses it.- Focus returns to the element that opened it, usually the trigger button.
- It has a role and a name: screen readers announce "dialog" and its title.
Why use the dialog element
Calling showModal() on a dialog gives you points 2, 3 and 4 natively, and modern browsers handle 1 and 5 as well. It also renders the dialog in the top layer, above everything else regardless of z-index, and provides a ::backdrop you can style. The dialog element is supported in all current major browsers.
A hand-built div role="dialog" needs a focus trap, inert or aria-hidden on the background, an Escape key handler and focus restoration, all written and maintained by you.
A complete confirmation dialog
<button type="button" id="open-delete">Delete saved search</button>
<dialog id="confirm-delete" aria-labelledby="confirm-title" aria-describedby="confirm-desc">
<h2 id="confirm-title">Delete this saved search?</h2>
<p id="confirm-desc">You will stop getting emails about new books that match it. You cannot undo this.</p>
<form method="dialog">
<button type="submit" value="cancel" autofocus>Cancel</button>
<button type="submit" value="delete">Delete search</button>
</form>
</dialog>
<p id="result" role="status"></p>
dialog {
max-width: min(32rem, calc(100vw - 2rem));
padding: 1.5rem;
border: 2px solid #0b0c0c;
border-radius: 0.5rem;
}
dialog::backdrop {
background: rgb(0 0 0 / 0.6);
}
dialog form {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
const opener = document.getElementById('open-delete');
const dialog = document.getElementById('confirm-delete');
const result = document.getElementById('result');
opener.addEventListener('click', () => {
dialog.returnValue = '';
dialog.showModal();
});
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'delete') {
result.textContent = 'Saved search deleted.';
}
// Browsers restore focus on close; doing it explicitly is a safe fallback.
opener.focus();
});
How it meets each requirement:
- Focus in:
showModal()moves focus to the element withautofocus, here Cancel. For a destructive action, start on the least destructive option so an accidentalEnterdoes no harm. - Focus contained and background inert: while the dialog is modal, the rest of the document is inert, so
Tabcannot reach it and screen readers do not read it. - Escape closes: the browser fires a
cancelevent, thenclose. - Buttons close it without JavaScript: a
formwithmethod="dialog"closes the dialog on submit and setsdialog.returnValueto thevalueof the button pressed. - Focus returns to the opener in the
closehandler, which runs whichever way the dialog closed. - Name and description: the
dialogrole is implicit.aria-labelledbynames it from the heading andaria-describedbyadds the warning, so screen readers announce something like "Delete this saved search?, dialog. You will stop getting emails…".
Resetting returnValue before each opening matters: closing with Escape does not change it, so an old value could otherwise be read again.
Use showModal(), not show(), for modals. show() opens a non-modal dialog: the page behind stays interactive and Escape does not close it. Do not use the open attribute to display a modal either, for the same reason.
Choosing where focus goes first
The APG gives practical guidance:
- In most dialogs, focus the first focusable element.
- If the content is long and the first control is at the bottom, put
tabindex="-1"on the heading or an introductory paragraph and give thatautofocus, so screen reader users start reading at the top. - For a destructive confirmation, focus the least destructive action.
Avoid focusing the close button first in a long dialog; screen reader users then skip the content.
Closing by clicking the backdrop
Some designs close the dialog when users click outside it. Clicks on ::backdrop are reported on the dialog element itself, as are clicks on the dialog's own padding, so check the target and then compare the click position with the dialog's box. The target check also matters for keyboard users: pressing Enter on a button fires a click with coordinates of zero, which would otherwise look like a click outside.
dialog.addEventListener('click', (event) => {
// Clicks on content target the child element; only the dialog box and backdrop target the dialog.
if (event.target !== dialog) return;
const box = dialog.getBoundingClientRect();
const inside =
event.clientX >= box.left && event.clientX <= box.right &&
event.clientY >= box.top && event.clientY <= box.bottom;
if (!inside) dialog.close();
});
This is an extra, not a replacement for a visible close or cancel button and Escape. Do not add it to dialogs where losing typed data would be a problem.
Close buttons
Every modal needs a visible way to close it. If you use an icon, give it a name.
<form method="dialog">
<button type="submit" aria-label="Close">
<svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 20 20">
<path d="M4 4l12 12M16 4L4 16" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</form>
Alert dialogs
When a dialog interrupts the user with an urgent message that needs a response, such as "Your session will end in 2 minutes", the APG recommends role="alertdialog". Some screen readers announce it more urgently. The behaviour is the same, and it still needs a name and a description.
<dialog id="timeout" role="alertdialog" aria-labelledby="timeout-title" aria-describedby="timeout-desc">
<h2 id="timeout-title">Your session is about to end</h2>
<p id="timeout-desc">You will be signed out in 2 minutes to protect your information.</p>
<form method="dialog">
<button type="submit" value="stay" autofocus>Stay signed in</button>
<button type="submit" value="leave">Sign out</button>
</form>
</dialog>
A time limit like this also relates to 2.2.1 Timing Adjustable (A), which requires that users can turn off, adjust or extend most time limits. Warning users and letting them extend with a simple action is one way to meet it.
Common mistakes
- Opening a modal on page load or on a timer. Unexpected modals disorient screen reader and magnifier users.
- Making the dialog taller than the viewport with no scrolling. Let the dialog scroll, and check it at 400% zoom.
- Placing important content such as error messages outside the dialog, where inert content cannot be reached.
- Forgetting to remove scroll locks or other page changes when it closes.
- Nesting modals. Avoid opening a modal from a modal where you can.
Testing checklist
- Open the dialog with the keyboard. Does focus land inside, on a sensible element?
- Press
Tabrepeatedly. Does focus stay within the dialog and the browser interface? - With a screen reader, is "dialog" and its name announced? Can you read the page behind? You should not be able to.
- Press
Escape. Does it close, and does focus return to the trigger? - Close it with each button. Does focus return each time?
Summary
- Build modals with
dialogandshowModal(): the page behind becomes inert, focus is contained andEscapecloses it. - Use
autofocusto choose the first focus, and the least destructive action for confirmations. - Name the dialog with
aria-labelledbyand describe it witharia-describedby. - Return focus to the trigger on
close, and resetreturnValuebefore reopening. - Always provide a visible close or cancel button; backdrop clicks are only an extra.
- Use
role="alertdialog"for urgent interruptions that need a response.