Accessibility · Keyboard and Focus
Containing Focus Inside a Modal Surface
How the dialog element traps Tab natively, what a hand-rolled focus trap has to reimplement, and why returning focus to the opener is part of the containment contract.
A modal dialog makes a promise: while it is open, the page behind it is unavailable, and the user will return to the exact place they left. Keyboard users experience that promise as a closed loop of Tab presses. Mouse users experience it as an overlay. Implementations that satisfy the second and not the first leave a keyboard user tabbing through controls they cannot see, which is a failure of the modal contract rather than a cosmetic defect.
What the platform gives for free
<dialog> opened with showModal() enters the top layer and becomes modal in a way the browser
enforces. Three things follow that no script is required to arrange:
The rest of the document becomes inert. It is removed from the tab sequence, from hit testing, and from the accessibility tree, so Tab from the last focusable element inside the dialog does not continue into the page behind it — it wraps, or it moves to the browser chrome, depending on the engine, but it never lands on a background control.
The dialog receives focus on open, through the specified dialog focusing steps: the element with
the autofocus attribute inside the dialog if there is one, otherwise the dialog’s focus delegate —
which is the first focusable descendant, or a descendant marked autofocus — and failing both, the
dialog element itself. The autofocus attribute is scoped: inside a dialog it applies when the
dialog is shown, whereas on a page it applies at load and can steal focus from the document. The
steps also bail out entirely if the document is not allowed to take focus, which is why showing a
modal from a background tab can leave focus where it was.
Escape closes it. The cancel event fires, and unless the handler calls preventDefault(), the
dialog closes. A dialog that should not be dismissible must cancel that event, and the author then
owes the user an explicit close control, since the platform’s escape hatch has been removed.
showModal() also sets the dialog’s :modal pseudo-class, which is the only reliable way to style
“this dialog is the modal one” as opposed to a non-modal <dialog> opened with show().
The trap a hand-rolled dialog must build
Before <dialog> was widely usable, and still in component libraries that render their own overlay,
containment has to be assembled from parts. The parts are: a keydown handler on the container that
intercepts Tab and Shift+Tab, an explicit list of focusable descendants, and a sentinel at each end.
function trapTab(container) {
const selector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]),' +
' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const focusable = () => [...container.querySelectorAll(selector)]
.filter((el) => el.offsetParent !== null || el === document.activeElement);
container.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
const items = focusable();
if (items.length === 0) { event.preventDefault(); return; } // nothing to move to: keep focus here
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
}
Four defects in that sketch are worth naming, because each one appears in real implementations.
The offsetParent !== null visibility test is a heuristic, not a rendering query. It returns null
for elements with position: fixed, so a focusable element positioned that way inside the dialog is
silently dropped from the list and becomes unreachable. It also does not account for
visibility: hidden, for an ancestor with inert, or for a disabled fieldset ancestor.
The listener is on the container, so it only fires when the event’s target is inside the container.
If focus has already escaped — because the dialog opened without focusing anything, and focus stayed
on the trigger behind it — the handler never runs and the trap does nothing. Containment requires
that focus be moved inside on open, and that the move be verified, since a refused focus() call
leaves the trap inert.
Nothing here prevents focus from leaving by other routes. Clicking a background control, or a
script calling focus() on an element outside the container, moves focus out and the Tab handler
no longer sees events — and a frame inside the dialog receives key events in its own document, so
the handler never runs at all. Focus Across Shadow Roots and Frame
Boundaries covers that
boundary in detail. Real containment needs the background to be inert, or at minimum
aria-hidden plus tabindex="-1" on everything behind the overlay, and the second option is a
maintenance burden that grows with every new component. The reason the two attributes are not
equivalent is covered in Which Elements Can Actually Take
Focus: aria-hidden
removes the accessibility tree without touching the sequence.
The wrap is unconditional. A dialog containing exactly one focusable element traps the user on that element, which is correct for a modal but indistinguishable from a keyboard trap if the element is also the close button and Escape has been cancelled. WCAG 2.1.2 exists precisely for this: a keyboard trap is a state from which focus cannot be moved away using only the keyboard, and a modal with no exit satisfies the definition.
Inertness is the mechanism, and it composes
The inert attribute is the general form of what showModal() does to the background. Applying
inert to every sibling of the dialog’s top-level ancestor produces the same removal of the
background from keyboard and assistive technology, without the top layer. This is the approach that
works when the overlay must live inside a component tree rather than at the end of the body, and it
is the approach that fails if the attribute is applied to the wrong level — in particular, applying
inert to a container that also contains the dialog inerts the dialog itself.
Two details make inertness hard to retrofit. It is inherited, so an inert ancestor cannot be
escaped by a descendant; there is no “un-inert” attribute. And it suppresses the accessibility tree
for the subtree, so a visually hidden live region outside the dialog — the one announcing that a
dialog opened — is silenced along with everything else. Live regions used during a modal flow must
be inside the modal subtree, or must be re-inserted after the inertness is applied.
Return of focus is part of containment
Closing the dialog should restore focus to the element that opened it. Without that, focus falls to the body and the next Tab press starts at the top of the document; for a keyboard user who opened a dialog from the middle of a long form, the return trip costs every Tab press in between.
For the dialog element the specification now defines this. Each dialog carries a previously
focused element, set when show() or showModal() runs, before the dialog focusing steps; on
close, if that element is still non-null and either the dialog was modal or focus is currently
inside the dialog, the focusing steps run for it again, with scrolling suppressed so the page does
not jump. A dialog that opens and closes by itself therefore returns focus to its trigger.
That behaviour is a recent addition to the specification rather than a long-standing platform
guarantee, so an overlay that renders its own DOM — a div in the top layer or an absolutely
positioned panel — has none of it and must implement the whole contract by hand. Recording the
opener before showing and refocusing it after closing is the shape of the implementation:
let opener = null;
function openOverlay(overlay, trigger) {
opener = trigger;
overlay.hidden = false;
if (!overlay.hasAttribute('tabindex')) overlay.setAttribute('tabindex', '-1');
const target = overlay.querySelector('[data-initial-focus]') ?? overlay;
target.focus(); // falls back to the container when it has no focusable content
}
function closeOverlay(overlay) {
overlay.hidden = true;
if (opener?.isConnected) opener.focus(); // the opener may have been removed while open
opener = null;
}
isConnected is the load-bearing check. A dialog opened from a row in a list, where the row is
deleted by the action the dialog performed, has no opener to return to. The general problem of
choosing a fallback target is the subject of Restoring Focus After the Focused Element
Disappears, and a
dialog close is the most common place it arises.
There is a further subtlety with nested dialogs. Two showModal() calls stack in the top layer, and
the second inerts the first. Closing the inner dialog returns focus to the outer, provided the
outer’s opener bookkeeping is separate — a single module-level opener variable is overwritten by
the second open and restores the wrong element. A stack, pushed on open and popped on close,
matches the platform’s own ordering.
What “modal” must not be used for
A modal surface disables the rest of the page, so a surface that does not actually require exclusive
attention should not use one. A non-modal <dialog> with show(), or a disclosure region, keeps the
page available and avoids the entire containment problem. The cost of modality is paid by every user
who must dismiss it to get back to their work, and by the implementation, which must get the trap,
the escape route, and the focus return all correct. The pattern is worth its cost only when the
interaction genuinely cannot proceed in parallel with the page behind it.