Accessible forms and dynamic UI · 6 / 8
lesson 6

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.

~ 17 min read·lesson 6 of 8
0 / 8

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:

  1. Focus moves into the dialog when it opens. Usually to the first focusable element, or the close button.
  2. Focus stays inside the dialog. Tabbing past the last focusable element wraps to the first; Shift+Tab past the first wraps to the last.
  3. Escape closes it. Standard. Don't override.
  4. Focus returns to the trigger (the button that opened it) when it closes. Not the body, not nowhere.
  5. 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."

DeleteCancelConfirm
Focus enters the modal, cycles inside it, and returns to the trigger button on close. The arrows show the only paths focus is allowed to take.

Native <dialog> and showModal

The HTML <dialog> element, called with .showModal(), gets you most of the contract for free:

dialog.html
<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's value becomes dialog.returnValue.

What it does not give you:

  • A guaranteed focus point — by default, focus goes to the first focusable element. Add autofocus on the button you actually want.
  • Click-on-backdrop to close. You wire that up yourself if you want it.
autofocus.html
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete account?</h2>
<button autofocus>Cancel</button>
<button>Confirm</button>
</dialog>
Tip

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:

focus-trap.js
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:

escape-close.js
modalEl.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
});

And on close, return focus:

restore-focus.js
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.

Watch out

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:

  1. Layout shift — adding overflow: hidden removes the scrollbar on Windows, content jumps right by ~15px. Compensate with scrollbar-gutter: stable on :root.
  2. iOS Safarioverflow: hidden on body doesn't always stop touch scroll. Use a stricter pattern: store the current scroll position, set body to position: 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-labelledby points at the dialog's own heading. Best when the heading is visible.
labelledby.html
<dialog aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete account?</h2>
</dialog>
  • aria-label when no visible heading exists.
aria-label.html
<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

check your understanding
What does .showModal() on a native <dialog> give you that .show() doesn't?
check your understanding
When the modal closes, where should keyboard focus go?
check your understanding
You have a 'Delete account' confirmation modal. Which button should receive initial focus?
check your understanding
What's a focus trap?
check your understanding
Why is the <dialog> element preferred over a hand-rolled div modal?
← prevnext lesson →
KeepLearningcertificate
for completing
Accessible forms and dynamic UI
0 of 8 read