Focus is where your key presses go: at any moment, one element on the page has it. Keyboard users move it with Tab and Shift+Tab, and screen readers read out whatever receives it. A focus trap keeps focus inside one part of the page while that part is open. You need one for a modal dialog: a box, such as "Rename file", that sits on top of the page and has to be answered or dismissed before you can use anything else.
Without a trap, Tab carries on past the dialog's last button into the page behind it. A keyboard user can end up on a link or a form field they can't see, because the dialog covers it. A screen reader user hears content from the page behind and may not realise the dialog is still open. A mouse user is kept from clicking behind a modal dialog, and keyboard users shouldn't be able to get there either.
The WAI-ARIA Authoring Practices, the W3C's guide to accessible widgets, set out the keys. Here's a dialog with a Name field, a Cancel button, and a Save button, opened from a Rename button:
| You press | Focus moves to | Why |
|---|---|---|
| Enter on Rename | The Name field | The dialog opens, and focus moves into it, usually to its first control. |
| Tab | Cancel | The next control in the dialog. |
| Tab | Save | The next control. |
| Tab | The Name field | Save is the last control, so focus wraps around to the first. |
| Shift+Tab | Save | From the first control, Shift+Tab wraps around to the last. |
| Escape | The Rename button | Escape closes the dialog, and focus goes back to the element that opened it. |
Putting focus back matters as much as keeping it in. When the element that has focus is hidden, it loses focus, and document.activeElement falls back to the page's <body>. So without that last step, a keyboard or screen reader user would lose their place and have to find their way back to where they were.
Tab only stops at elements that can take focus and are in the Tab order: links with an href, buttons and form fields that aren't disabled, and any element with a tabindex of 0 or more, plus a few others, such as contenteditable regions. An element with tabindex="-1" can be focused by a script but is skipped by Tab, and a hidden element, or anything inside one, can't take focus at all.
A trap covers the Tab key, but screen readers also have their own keys for reading through a page, which don't move focus. aria-modal="true" on the dialog asks them to stay inside it, and the inert attribute on the rest of the page takes it out of reach of Tab, clicks, and screen readers alike. The native <dialog> element, opened with showModal(), does that for you: it makes the page behind inert, moves focus inside, and closes on Escape.
The page has a button and a hidden dialog:
<button id="open" type="button">Delete account</button>
<div id="confirm" role="dialog" aria-modal="true" aria-labelledby="confirm-title" hidden>
<h2 id="confirm-title">Delete your account?</h2>
<label>Type DELETE to confirm <input id="confirm-text"></label>
<button id="cancel" type="button" data-close>Cancel</button>
<button id="delete" type="button">Delete</button>
</div>
Write createDialog(dialog), which returns { open, close }.
open() remembers the element that has focus (document.activeElement), removes hidden from dialog, and moves focus to the first focusable element inside it. Calling open() while the dialog is already open does nothing.
close() adds hidden back and returns focus to the element that had it before open(). Pressing Escape inside the open dialog closes it, and so does clicking any element inside it that has a data-close attribute.
While it's open, keep Tab inside: Tab on the last focusable element moves focus to the first, and Shift+Tab on the first moves focus to the last, both with preventDefault(). Leave every other Tab press alone, so the browser moves between the elements in between.
Focusable means links with an href; buttons, inputs, selects, and textareas that aren't disabled; and anything with a tabindex other than -1. Work them out when Tab is pressed, since they can change while the dialog is open.
const confirmDelete = createDialog(document.getElementById("confirm"));
document.getElementById("open").addEventListener("click", confirmDelete.open);
// focus "Delete account" and press Enter: the dialog shows, and the text field has focus
// press Escape: the dialog hides, and "Delete account" has focus again
const dialog = document.getElementById("confirm");
const modal = createDialog(dialog);
document.getElementById("open").focus();
modal.open();
expect(dialog.hidden).toBe(false);
expect(document.activeElement).toBe(document.getElementById("confirm-text"));
const dialog = document.getElementById("confirm");
const modal = createDialog(dialog);
modal.open();
fire(document.activeElement, "keydown", { key: "Escape" });
expect(dialog.hidden).toBe(true);
const opener = document.getElementById("open");
const modal = createDialog(document.getElementById("confirm"));
opener.focus();
modal.open();
modal.close();
expect(document.activeElement).toBe(opener);
modal.open();
fire(document.activeElement, "keydown", { key: "Escape" });
expect(document.activeElement).toBe(opener);
const opener = document.getElementById("open");
const dialog = document.getElementById("confirm");
const modal = createDialog(dialog);
opener.focus();
modal.open();
fire(document.getElementById("cancel"), "click");
expect(dialog.hidden).toBe(true);
expect(document.activeElement).toBe(opener);
const modal = createDialog(document.getElementById("confirm"));
modal.open();
const last = document.getElementById("delete");
last.focus();
const event = fire(last, "keydown", { key: "Tab" });
expect(event.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(document.getElementById("confirm-text"));
const modal = createDialog(document.getElementById("confirm"));
modal.open();
const event = fire(document.getElementById("confirm-text"), "keydown", { key: "Tab", shiftKey: true });
expect(event.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(document.getElementById("delete"));
const modal = createDialog(document.getElementById("confirm"));
modal.open();
const cancel = document.getElementById("cancel");
cancel.focus();
expect(fire(cancel, "keydown", { key: "Tab" }).defaultPrevented).toBe(false);
expect(fire(cancel, "keydown", { key: "Tab", shiftKey: true }).defaultPrevented).toBe(false);
expect(document.activeElement).toBe(cancel);
const modal = createDialog(document.getElementById("confirm"));
modal.open();
document.getElementById("delete").disabled = true;
const cancel = document.getElementById("cancel");
cancel.focus();
const event = fire(cancel, "keydown", { key: "Tab" });
expect(event.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(document.getElementById("confirm-text"));
const opener = document.getElementById("open");
const modal = createDialog(document.getElementById("confirm"));
opener.focus();
modal.open();
modal.open();
modal.close();
expect(document.activeElement).toBe(opener);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Modal dialog with focus management. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.