Skip to main content
ASoc
Tutorial

React Focus Trap: Two Dialogs Claimed aria-modal, One Meant It

An audit of four overlay surfaces: the wishlist panel that let Tab walk out of a modal dialog, the off-screen drawer pointer-events-none never hid, and the iframe case.

The ASoc Team10 min read

A focus trap keeps Tab cycling inside an open dialog instead of walking into the page behind it. React gives you no primitive for it — you attach a keydown listener, find the focusable children, and wrap the ends. This codebase has four overlay surfaces and two of them declare aria-modal="true". Before this post, one of those two actually trapped Tab. Here is the audit, both defects, and the version that handles a cross-origin iframe.

What a trap has to do, in four parts

Most tutorials cover the first item and stop. All four are load-bearing:

PartWhyFailure if skipped
Move focus in on openThe dialog is not where focus isTab starts from the page behind
Wrap Tab at both endsThe page behind is still focusableUser tabs out of a "modal" dialog
Restore focus on closeThe trigger is where they wereFocus falls to <body>; next Tab starts from the top
Escape closesKeyboard users need a way outA trap with no exit is exactly a trap

aria-modal="true" is a claim about all four. It tells assistive technology that everything outside this node is inert, and it does not make it so — the browser enforces nothing. Declaring it without wrapping Tab is worse than declaring nothing: the screen reader has already told the user the rest of the page is unavailable, and then Tab takes them there.

The census

Four surfaces in src/components attach a keydown listener while open:

Surfacerole="dialog" + aria-modalFocus inTab wrappedRestoreEscape
PreviewModal (live template preview)YesYesYesYesYes
SavedTemplates (wishlist panel)YesYesNo → fixedYesYes
DownloadMenu (edition dropdown)No — a menuNoNo, correctlyYesYes
Mobile nav drawer (Header)NoNoNoNoYes

Two of the four claim to be modal dialogs. One of those two wrapped Tab. The third row is not a defect: a dropdown menu is not a dialog, the page behind it stays live, and trapping focus in one would be wrong — Escape, an outside pointer-down and returning focus to the trigger is the whole contract, and that one already met it. The rest of that component's contract, including why its Escape handler runs in the capture phase so it does not take the modal behind it down too, is in the React dropdown walkthrough.

Defect 1: a dialog that says it is modal and is not

SavedTemplates is the wishlist panel behind the heart icon. It set role="dialog", aria-modal="true", locked body scroll, moved focus into the panel on open and returned it to the trigger on close — four of the five things, and then let Tab walk straight out into the header, the page, and the browser chrome, while the screen reader maintained that none of that existed.

The fix is the standard wrap, added to the handler that was already there for Escape:

const panel = panelRef.current;
if (e.key !== "Tab" || !panel) return;
const focusables = panel.querySelectorAll<HTMLElement>(
  'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === panel)) {
  e.preventDefault();
  last.focus();
} else if (!e.shiftKey && active === last) {
  e.preventDefault();
  first.focus();
}

Three details in there are not decoration. The selector excludes [tabindex="-1"], because an element deliberately removed from the tab order must not become the wrap point. The Shift+Tab branch also tests active === panel, because the panel itself is focused first on open — without that clause, the very first Shift+Tab out of a freshly opened dialog escapes. And the early return on an empty list means an empty wishlist does not deadlock: with nothing to focus, Tab behaves normally and Escape still closes.

Defect 2: an off-screen drawer that was still tabbable

The mobile nav drawer is not a dialog and does not claim to be, so the table above marks its missing trap as a non-issue. It had a different problem, and it is the one worth remembering because pointer-events-none is such a common way to express "closed":

className={`… transition-transform … ${navOpen ? "translate-x-0" : "pointer-events-none translate-x-full"}`}

The drawer is never unmounted — it is the same element that becomes the desktop nav bar at xl. Closed, it sits translated one width off-screen with pointer events disabled. pointer-events-none stops the mouse. It does not stop the Tab key. So on any viewport below xl, a keyboard user tabbing from the logo hit every link in a drawer they could not see, with the focus ring rendering somewhere outside the viewport.

translate-x-full does not remove anything from the tab order either — a transformed element is still laid out, still visible to the accessibility tree, still focusable. The properties that do remove it are display: none, visibility: hidden, the hidden attribute, and inert. Only one of those is usable here, because the element has to stay interactive at xl where it is the desktop nav:

${navOpen ? "visible translate-x-0" : "invisible translate-x-full pointer-events-none"}

with transition-[transform,visibility] on the base and xl:visible alongside the other xl: overrides. Listing visibility in the transition is what preserves the animation: the property is discrete, so it holds its old value for the duration and flips at the end, which means the drawer slides out fully and only then leaves the tab order. This is the same rule the repo already applies to its hover overlays — an invisible child cannot be focused — reached from the other direction.

The case the tutorials do not cover: a cross-origin iframe

PreviewModal renders a live template preview in an <iframe> pointed at another origin. The Tab wrap above cannot hold it, and understanding why is the difference between a trap that works and one that looks like it does.

Tab into the iframe and focus enters a document this page cannot see into. Keystrokes there never reach a keydown listener on the parent's document, so when the embedded page's own focusable elements run out, the browser continues to the next thing in the parent — which is whatever sits behind the dialog. The handler is never called, so there is nothing to prevent.

The answer is to stop catching the departure and catch the arrival instead:

const onFocusIn = (e: FocusEvent) => {
  if (!dialog || dialog.contains(e.target as Node)) return;
  const first = dialog.querySelector<HTMLElement>(
    'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
  );
  (first ?? dialog).focus();
};
document.addEventListener("focusin", onFocusIn);

focusin bubbles, unlike focus, so one listener on document sees every focus change in the parent document. Anything that gains focus outside the dialog is sent back to its first control — regardless of how focus got out. Note that the Tab-wrap selector includes iframe and this one does not: the iframe is a legitimate stop inside the dialog, but never the place to bounce back to.

The two handlers are deliberately kept as two, and this trap is deliberately not shared with the wishlist panel's. focusin alone would be a strictly worse trap: it corrects focus after it has already moved, which a screen reader may announce, so the Tab wrap stays the primary mechanism and the bounce is the backstop for the one exit it cannot see. Two call sites with different requirements is not yet an abstraction.

Do you need a library?

focus-trap-react is about 5 KB gzipped and handles cases this code does not: focus moving into a portal, MutationObserver on the focusable set, nested traps, inert on siblings. Reach for it when a dialog's contents change while open, when dialogs stack, or when you have more than two of them. Below that, the wrap is roughly twenty lines and adds nothing to the bundle — this codebase carries zero dialog dependencies for two dialogs.

What you should not do is skip the trap because writing it looked fiddly. An untrapped aria-modal dialog is not a partial implementation; it is an incorrect statement to the users who depend on it most.

Common mistakes

MistakeWhat happensFix
aria-modal="true" with no Tab wrapAT says the page is inert; Tab proves otherwiseWrap Tab, or drop the attribute
pointer-events-none to hide a panelMouse blocked, still in the tab orderinvisible / hidden / inert
translate-x-full alone as "closed"Element is off-screen and fully focusableSame
Selecting [tabindex="-1"] as focusableWrap point is an element you excluded:not([tabindex="-1"])
Forgetting active === dialog in the Shift branchFirst Shift+Tab escapes the dialogTest the container too
No focus restore on closeFocus falls to <body>Store document.activeElement, restore in cleanup
Trapping focus in a dropdown menuMenus are not modalEscape + outside click + restore is the contract

Frequently asked questions

Does the inert attribute replace all of this? It replaces half of it, well: inert on everything outside the dialog removes those subtrees from the tab order and the accessibility tree, so Tab has nowhere else to go. It is now broadly supported and is the cleanest approach for a portalled dialog. It does not help here, because the drawer and the nav bar are the same element at different breakpoints and inert is not conditional on a media query — and it still does not move focus in or restore it on close.

Why focusin rather than focus? focus does not bubble, so catching it on document requires capture-phase listening and still misses cases. focusin bubbles by design, which is why one listener on document is enough to observe every focus change in the page.

Does a focus trap affect SEO or Lighthouse? Not SEO — it is behaviour, not markup, and a crawler never presses Tab. Lighthouse will flag a missing aria-modal or a dialog without an accessible name, but it cannot detect that Tab escapes: that requires simulating a key press through the whole tab order. Both defects in this post sat inside a build scoring 100 for accessibility.

How do I test it without a screen reader? Open the dialog, then press Tab about fifteen times and watch where the focus ring goes. If it leaves the dialog once, the trap is broken. Then Shift+Tab from the first control, which is the check most people skip and the one that catches a missing active === dialog clause. document.activeElement in the console after each press is the same test, written down.

Templates where this pattern ships

ASoc Canvas, ASoc Catalyst and ASoc Chain each ship a mobile drawer and at least one overlay, which is the exact pair of surfaces audited above — one that must trap focus and one that must simply stop being reachable when closed.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the nav element under the drawer, read HTML nav: the element, not the layout; for the focus ring these controls draw, every button here is an anchor; for the other invisible-to-Lighthouse a11y defect this codebase shipped, five live regions that announced nothing.

Keep reading

Tutorial10 min read

React Form Validation: The Allowlist Is the Validation

Client-side checks are UX. The server-side function that reads three named fields and ignores everything else — proven by a test that smuggles in a fake user_id — is the actual gate.

Read more
Tutorial9 min read

React Hooks: Why This Codebase's `useContext` Count Is Zero

24 useState, 13 useEffect, 0 useContext across 22 files — a real hook census, and the module-scope store pattern this codebase uses instead of Context.

Read more
Tutorial8 min read

Skipping React Hook Form: What Four Real Forms Look Like Without One

Login, signup, password reset and contact all run on FormData, a Server Action and useActionState — zero form libraries in package.json. Here's the actual validation code.

Read more