Modal dialogs, properly
A modal isn't just a centered div with a backdrop. It's a focus contract — and breaking it strands keyboard users.
A modal dialog is a window that blocks interaction with everything behind it until the user finishes or dismisses it. Sounds simple. The implementation is famously full of traps: focus that wanders out the bottom, an Escape key that does nothing, scroll that bleeds through to the page below, and the bonus prize where focus returns to the top of the document instead of the button that opened the dialog. We'll go through the contract piece by piece, then meet the native <dialog> element that handles most of it for you.
The five-part modal contract
A modal is correct when all of these hold:
- Focus moves into the dialog when it opens. Usually to the first focusable element, or the close button.
- Focus stays inside the dialog. Tabbing past the last focusable element wraps to the first; Shift+Tab past the first wraps to the last.
- Escape closes it. Standard. Don't override.
- Focus returns to the trigger (the button that opened it) when it closes. Not the body, not nowhere.
- The page underneath is inert. Background scroll is locked, and screen readers don't read content behind the modal.
Miss one and keyboard users lose. Miss two and screen reader users lose. Miss all five and you've shipped what's politely called "a modal in name only."
Native <dialog> and showModal
The HTML <dialog> element, called with .showModal(), gets you most of the contract for free:
<button id="open-btn">Delete account</button>
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete account?</h2>
<p>This cannot be undone.</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm">Confirm</button>
</form>
</dialog>
<script>
const btn = document.getElementById("open-btn");
const dlg = document.getElementById("confirm");
btn.addEventListener("click", () => dlg.showModal());
</script>What .showModal() gives you automatically:
- Background goes inert (page underneath can't be tabbed into or read).
- Escape closes the dialog and focus returns to the element that called
.showModal(). - The browser draws a backdrop you can style with
dialog::backdrop. - A
form method="dialog"inside it submits-and-closes — the button'svaluebecomesdialog.returnValue.
What it does not give you:
- A guaranteed focus point — by default, focus goes to the first focusable element. Add
autofocuson the button you actually want. - Click-on-backdrop to close. You wire that up yourself if you want it.
<dialog id="confirm" aria-labelledby="confirm-title"> <h2 id="confirm-title">Delete account?</h2> <button autofocus>Cancel</button> <button>Confirm</button> </dialog>
Focus the safer action by default — Cancel rather than Confirm — so an absent-minded Enter doesn't delete an account.
Focus trap, by hand
If you can't use native <dialog> (legacy code, custom render boundaries, framework constraints), here's the manual version. Three pieces:
function trapFocus(modalEl) {
const focusables = modalEl.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
modalEl.addEventListener("keydown", (e) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === first) {
last.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus();
e.preventDefault();
}
});
}Then on Escape:
modalEl.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
});And on close, return focus:
let lastTrigger;
function openModal(triggerEl) {
lastTrigger = triggerEl;
// … show dialog, move focus inside …
}
function closeModal() {
// … hide dialog …
lastTrigger?.focus();
}This is exactly the work <dialog> does for you. Reach for native first; only build this from scratch when you have to.
Returning focus to document.body is the default if you do nothing — and it's wrong. Always store the trigger before opening and restore to it on close.
Scroll lock without breaking the page
When the modal is open, you don't want background content scrolling under the user's wheel or finger. The classic fix is overflow: hidden on <body> while the modal is open. Two gotchas:
- Layout shift — adding
overflow: hiddenremoves the scrollbar on Windows, content jumps right by ~15px. Compensate withscrollbar-gutter: stableon:root. - iOS Safari —
overflow: hiddenon body doesn't always stop touch scroll. Use a stricter pattern: store the current scroll position, set body toposition: fixed; top: -<scroll>px, restore on close.
For native <dialog> with .showModal(), the browser handles inertness — your scroll-lock concerns mostly go away. Another reason to prefer it.
Labelling the dialog itself
A dialog needs an accessible name so a screen reader announces what kind of dialog it is when it opens. Two patterns:
aria-labelledbypoints at the dialog's own heading. Best when the heading is visible.
<dialog aria-labelledby="confirm-title"> <h2 id="confirm-title">Delete account?</h2> </dialog>
aria-labelwhen no visible heading exists.
<dialog aria-label="Confirm deletion">…</dialog>
For confirmation dialogs that demand user attention before any other action — "Save before quitting?" — use role="alertdialog" instead of the default. It tells assistive tech "this is more urgent than a regular dialog."
Try it yourself
.showModal() on a native <dialog> give you that .show() doesn't?