React Modal, Zero Dependencies: The Iframe Focus Bug We Fixed
A real accessible React modal with zero dependencies — focus trap, ARIA dialog role, restore-on-close, and the iframe-focus-escape bug most modal libraries never mention.
A React modal needs four things to be correct, not decorative: it traps focus while open, restores it on close, announces itself to assistive tech as a dialog, and closes on Escape. Most hand-rolled examples get one or two. Ours gets all four, plus one thing no library in the top search results mentions at all — what happens when focus tabs into a cross-origin iframe the modal is framing, and never comes back.
That modal is real, ships on every product page in this catalog, and has zero runtime dependencies. Here is what it actually takes to get right, and the bug that only shows up once your modal contains something other than static content.
What a modal needs, and what a library buys you
| | Hand-rolled (this codebase) | react-modal | MUI Modal | Radix / React Aria |
|---|---|---|---|
| Focus trap | Custom keydown handler | Built in | Built in | Built in, more thorough |
| Restore focus on close | Custom, activeElement snapshot | Built in | Built in | Built in |
| ARIA role + aria-modal | Manual | Built in | Built in | Built in |
| Portal / stacking multiple modals | Not needed — one modal at a time | Manual | Built in | Built in |
| Animation orchestration | Not needed — no exit animation | Manual | Built in | Manual (Framer Motion pairing) |
| Iframe content inside | Handled — see below | Not mentioned in docs | Not mentioned in docs | Not mentioned in docs |
| Bundle cost | 0 KB | ~5 KB | Part of MUI's larger surface | ~10–15 KB per primitive |
The right column isn't wrong to reach for — if your app stacks multiple modals, needs exit animations, or is already built on a design system with dialog primitives, use the library and get the focus-management edge cases handled by people who test them continuously. The case for skipping one is narrower than "modals are easy": it holds when you have exactly one modal shape, no stacking, and a genuine reason to control every pixel of what's inside it — which is our actual situation, because what's inside ours is somebody else's website.
The component: a live-preview dialog, not a generic modal
PreviewModal (src/components/molecules/PreviewModal.tsx) is the "Live preview" dialog that opens from every product card and product page in this marketplace. It renders a cross-origin <iframe> pointed at the template's real deployed preview, inside a dialog with a device-width toggle. That last detail — the content is someone else's page, not markup we control — is what makes the standard focus-trap recipe incomplete.
The dialog shell is unremarkable and correct:
// src/components/molecules/PreviewModal.tsx
return (
<div
aria-label={`${title} live preview`}
aria-modal="true"
role="dialog"
className="fixed inset-0 z-[10000] flex h-[100dvh] flex-col overscroll-none bg-gray-900/80 backdrop-blur-sm"
ref={dialogRef}
tabIndex={-1}
>
{/* header, device toggle, iframe */}
</div>
);
role="dialog" plus aria-modal="true" is the whole ARIA contract — nothing exotic. The interesting part is the effect that runs while it's open:
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") return onClose();
if (e.key === "Tab" && dialog) {
const focusables = dialog.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, iframe, [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 === dialog)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", onKey);
dialog?.focus();
return () => {
document.removeEventListener("keydown", onKey);
previouslyFocused?.focus();
};
}, [open, onClose]);
Escape closes. Tab wraps at the last focusable element back to the first, and Shift+Tab wraps the other way. On close, focus returns to whatever triggered the modal (previouslyFocused) — the step almost every "build a modal" tutorial skips, and the one that matters most for someone navigating by keyboard: without it, focus silently resets to <body> and they lose their place in the page entirely.
That keydown listener is the textbook focus trap, and it is where every library-comparison article stops. It is also not sufficient here, because the trap watches for Tab events, and the iframe is in the list of focusables.
The bug: tabbing into an iframe is a one-way trip
Tab an <iframe> element itself and focus lands on the frame — fine, the trap's keydown listener still sees the keystroke because it's attached to document. But tab again, and focus moves to whatever's focusable inside that iframe's document. That's a different document, with its own event listeners, and ours never fires again. The user tabs through the entire preview site — someone else's nav, someone else's buttons — and when they run out, the browser walks straight past the iframe's boundary into whatever sits behind the modal on the real page.
No error, no visual glitch. The dialog is still on screen. Focus is just gone from it.
The fix doesn't try to intercept keystrokes inside a cross-origin document — you can't, contentDocument throws on read for a different origin. It catches the arrival instead of the departure, with a focusin listener that fires the moment focus lands anywhere in document, including via the browser's own tab-order walk that skipped past the iframe:
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);
If focus ever lands outside the dialog — which, given the iframe, it eventually will — it gets bounced straight back to the dialog's first real control (deliberately excluding the iframe itself from that query, so it doesn't just re-enter the frame and repeat the escape). The keydown trap and the focusin net cover two different failure directions: one polices how focus tries to leave, the other polices where it ends up regardless of how it got there. A modal with only static content never needs the second listener, because nothing inside it can hand focus to a different document. A modal framing someone else's page always does.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
| No focus restore on close | Keyboard focus resets to <body>, user loses their place | Snapshot document.activeElement on open, .focus() it on close |
Trapping keydown only, with an iframe inside | Tabbing twice into the iframe escapes the trap silently | Add a focusin listener that bounces stray focus back |
overflow: hidden forgotten on <body> | Page scrolls behind the modal while it's open | Set document.body.style.overflow = "hidden" on open, restore on close |
role="dialog" with no aria-modal | Screen readers still announce content behind the modal | Add aria-modal="true" alongside the role |
| Closing on outside-click only | Keyboard-only users have no way out without a mouse | Escape must always close, independent of click handling |
| Rendering the modal's markup even when closed | Focusable elements exist off-screen, still reachable by Tab | if (!open) return null; — don't just hide with CSS |
Assuming contentDocument is readable to detect iframe focus | Throws on any cross-origin iframe, breaks in production only | Catch focus arriving outside the dialog instead of watching inside the frame |
Frequently asked questions
Do I need a focus trap if my modal has no interactive content? Yes, if it can be closed with a button — that button, plus Escape, are focusable, so the trap (even a minimal one) still has to keep Tab cycling between them rather than escaping to the page.
Why not just use <dialog>, the native HTML element?
It's a legitimate option and does give you a free focus trap and Escape-to-close via showModal(). It doesn't help with the iframe-focus-escape case above — that's specific to framing another origin's document, not to how the dialog itself is implemented — and it needs its own work to look consistent across browsers. Worth it for a plain content modal; this component needed the manual version anyway once the iframe was in scope.
Does inert replace a manual focus trap?
Partially. Applying inert to everything outside the dialog stops focus from reaching it via Tab in modern browsers, which is cleaner than intercepting keystrokes. It doesn't address the iframe case either — inert governs your own document's focus order, not what happens once focus is inside a different document entirely.
Is aria-hidden on the backdrop necessary if I'm already using inert?
No — inert already removes the backdrop from both the focus order and the accessibility tree, so a separate aria-hidden is redundant on the same element.
Templates with this component's admin surfaces
ASoc Apex Admin is a large multi-purpose admin with a deep UI kit — the natural home for a dialog/modal pattern library beyond a single preview use case. ASoc Clover Admin is CRM-focused, where confirmation and record-detail modals are a constant UI need. ASoc Vertex Admin ships a full ecommerce dashboard shell where product-quick-view modals follow the same trap-and-restore rules as this one.
Browse the full set of React admin templates, Next.js admin templates, or Tailwind admin templates.
