Dialogs and focus traps

Open a modal that takes focus, keeps it, closes on Escape, and gives it back.

11of 16
≈ 12Min read
4Parts

What a modal owes

Miss the first and a screen reader user never hears the dialog. Miss the second and they wander into the page behind it, reading things they cannot see. Miss the last and they are returned to the top of the document.

  • Focus goes in when it opens — to the dialog itself, or to the first control in it.
  • Focus stays in while it is open: Tab from the last control returns to the first. Everything behind it is inert.
  • Escape closes it without saving.
  • Focus comes back to whatever opened it, so the user is where they left off.
  • It has a name, and announces itself as a dialog.

<dialog> does almost all of it

showModal() gives you the focus trap, the inert background, the Escape key and the backdrop, all from the platform. show() — without "modal" — does none of that and is for a non-modal popover. The only thing left to you is returning focus on close.

and it is in every current browser
<dialog id="quote" aria-labelledby="quote-title">
  <h2 id="quote-title">Ask for a quote</h2>

  <form method="dialog">
    <button value="cancel">Cancel</button>
    <button value="send">Send</button>
  </form>
</dialog>

<script>
const dialog = document.querySelector("#quote");

opener.addEventListener("click", () => dialog.showModal());   // focus in, background inert, Escape works
dialog.addEventListener("close", () => opener.focus());       // the one part you still write
</script>
aria-modal and role are already there

A <dialog> opened with showModal() has role="dialog" and modal semantics. Adding aria-modal="true" by hand is only needed when you are building one out of a <div> — and then you also owe the trap, the inert background and the Escape handler.

If you must build it by hand

inert is the modern way to make everything behind the dialog unreachable — it removes the subtree from the tab order and from the accessibility tree in one attribute, which is exactly the "background is not there" behaviour a modal needs.

the four pieces, in order
let lastFocused = null;

function open() {
  lastFocused = document.activeElement;

  panel.hidden = false;
  panel.setAttribute("role", "dialog");
  panel.setAttribute("aria-modal", "true");
  document.querySelector("main").setAttribute("inert", "");   // everything behind it

  panel.querySelector("h2").setAttribute("tabindex", "-1");
  panel.querySelector("h2").focus();
}

function close() {
  panel.hidden = true;
  document.querySelector("main").removeAttribute("inert");
  lastFocused?.focus();
}

document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !panel.hidden) close(); });

Things that are not dialogs

  • A cookie banner that blocks the page is a modal, whether you called it one or not — it needs all of the above.
  • A toast is not a dialog: it does not take focus. It is a live region, which is the next lesson.
  • A menu or a popover is not modal: Escape should close it and focus should return, but the page behind stays usable.
  • A full-screen loading overlay should not trap focus; mark it aria-busy and leave the page alone.

Practice