Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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

MistakeSymptomFix
No focus restore on closeKeyboard focus resets to <body>, user loses their placeSnapshot document.activeElement on open, .focus() it on close
Trapping keydown only, with an iframe insideTabbing twice into the iframe escapes the trap silentlyAdd a focusin listener that bounces stray focus back
overflow: hidden forgotten on <body>Page scrolls behind the modal while it's openSet document.body.style.overflow = "hidden" on open, restore on close
role="dialog" with no aria-modalScreen readers still announce content behind the modalAdd aria-modal="true" alongside the role
Closing on outside-click onlyKeyboard-only users have no way out without a mouseEscape must always close, independent of click handling
Rendering the modal's markup even when closedFocusable elements exist off-screen, still reachable by Tabif (!open) return null; — don't just hide with CSS
Assuming contentDocument is readable to detect iframe focusThrows on any cross-origin iframe, breaks in production onlyCatch 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.

Keep reading

Tutorial8 min read

React Multi-Step Forms: 1 of 5 Form Components Here Needs One

Most tutorials assume a step-index useState pattern by default. This codebase's one real example is two states confirming an irreversible choice, not more fields.

Read more
Tutorial9 min read

React Images: 26 `<img>` Tags and Not One `import`

1,160 image files, zero imported ones. The import-vs-string-path choice is really a choice about who verifies the path — and what you have to build once the bundler stops.

Read more