DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on • Originally published at github.com

I built a tool that shows how an accessible React modal really works: createPortal + focus trap, made visible

A "React modal" is usually a <div> with a dark overlay. A modal that actually works — that a screen-reader user and a keyboard user can use, that isn't clipped by some parent's overflow: hidden — has to do two invisible things right. I built an app that makes both visible so you can watch them happen.

▶ Live demo: https://portal-focus-trap.vercel.app/
Source (React 19 + TS): https://github.com/dev48v/portal-focus-trap

Problem 1: the modal is trapped in the DOM tree

Say your <Modal> is rendered deep in the tree, inside a card that has overflow: hidden (for rounded corners) and a transform (for a hover animation). Both are extremely common. Two things break:

  • overflow: hidden clips your fixed overlay to the card's box.
  • A transform on an ancestor creates a new containing block, so your position: fixed becomes fixed to the card, not the viewport — and z-index is trapped in that ancestor's stacking context, so the modal can render behind other page chrome no matter how high you crank it.

createPortal fixes this by decoupling where the component lives from where its DOM goes:

return createPortal(
  <Dialog />,
  document.getElementById("portal-root")   // a node at the end of <body>
);
Enter fullscreen mode Exit fullscreen mode

The component stays exactly where it is in the React tree — state, context, and even event bubbling still work as if it were a normal child — but the actual DOM nodes are mounted at #portal-root, outside every clipping ancestor. In the demo you can toggle the portal off and watch the modal get physically clipped inside its parent, then toggle it on and watch the DOM teleport to #portal-root.

Problem 2: focus escapes the dialog

Open a modal and press Tab. If focus moves to links behind the overlay, keyboard and screen-reader users are lost — they're "in" a dialog but tabbing through a page they can't see. An accessible dialog traps focus:

useEffect(() => {
  previouslyFocused.current = document.activeElement; // remember trigger
  focusable(dialog)[0]?.focus();                      // focus first control
  document.body.style.overflow = "hidden";            // lock scroll

  const onKey = (e) => {
    if (e.key === "Escape") return close();
    if (e.key !== "Tab") return;
    const f = focusable(dialog);
    const active = document.activeElement;
    if (e.shiftKey && active === f[0]) { e.preventDefault(); f.at(-1).focus(); }
    else if (!e.shiftKey && active === f.at(-1)) { e.preventDefault(); f[0].focus(); }
  };
  document.addEventListener("keydown", onKey);

  return () => {
    document.removeEventListener("keydown", onKey);
    document.body.style.overflow = "";
    previouslyFocused.current?.focus();               // return focus on close
  };
}, [open]);
Enter fullscreen mode Exit fullscreen mode

Four things that matter and are easy to forget:

  1. Move focus in on open — to the first focusable element, so the keyboard user starts inside.
  2. Wrap at both ends — Tab past the last control loops to the first; Shift+Tab past the first loops to the last.
  3. Escape and backdrop close — standard expectations.
  4. Return focus to the trigger on close — this is the one people skip, and it's the one that makes keyboard navigation feel sane. Focus should never get dumped back at the top of the page.

Add role="dialog", aria-modal="true", and aria-labelledby pointing at the title, and now assistive tech announces it as a dialog. The demo has a live focus monitor showing which element is focused and the last key pressed, so you can watch the wrap happen.

Why hand-roll it?

In production you'd probably reach for @headlessui/react, @radix-ui/react-dialog, or focus-trap-react — and you should; they handle edge cases like iframes, inert, and initial-focus overrides. But those libraries are exactly these two mechanisms plus polish, and if the mechanisms are a black box you can't debug them when they misbehave. Seeing the ~30 lines that make a modal accessible demystifies the whole category.

Open the demo, Tab around, hit Escape, and toggle the portal to watch the clipping bug appear and disappear. If it made portals and focus traps click, a star helps others find it: https://github.com/dev48v/portal-focus-trap

Top comments (0)