Issue №131 Summer 2026A journal of dev tools, libraries & small ideasUpdated weekly
frontend · 5 min read
frontendJuly 6, 2026 · 5 min

headless-modal

headless-modal is an accessible, headless modal / dialog primitive for React — it owns the behavior that's easy to get wrong (focus trap, focus return, body scroll lock with scrollbar compensation, portal rendering, Escape and overlay-click dismissal, and the full WAI-ARIA dialog wiring) and ships z

headless-modal — live demo screenshot
01
frontend

I built a headless modal for React (and designed around a StrictMode trap)

A modal looks trivial. Show a box, dim the background, done. Then you try to make it accessible and look like your product, and you discover that "show a box" was hiding a surprising amount of behavior.

Here's what a correct modal actually has to do:

  • Trap focus. While it's open, Tab and Shift+Tab cycle inside the dialog. Focus must never land on the page behind it.
  • Return focus. When it closes, focus goes back to whatever opened it — otherwise a keyboard user is dumped at the top of the document.
  • Lock scroll. The body stops scrolling while the dialog is open, and ideally the page doesn't lurch sideways when the scrollbar disappears.
  • Portal out. The content renders at the end of document.body so it escapes overflow: hidden and z-index stacking traps.
  • Dismiss predictably. Escape closes it. Clicking the backdrop closes it. Clicking inside the dialog does not.
  • Announce itself. role="dialog", aria-modal, and a title/description wired up for screen readers.

Most hand-rolled modals nail two or three of these. So I built headless-modal to get all of them — while shipping zero styling.

What it does

It's headless: the library owns the behavior, you own every pixel. There's a small set of composable parts for the common case —

<Modal>
  <ModalTrigger className="btn">Delete</ModalTrigger>
  <ModalPortal>
    <ModalOverlay className="overlay">
      <ModalContent className="dialog">
        <ModalTitle>Delete this item?</ModalTitle>
        <ModalDescription>This can't be undone.</ModalDescription>
        <ModalClose className="btn">Cancel</ModalClose>
      </ModalContent>
    </ModalOverlay>
  </ModalPortal>
</Modal>

— and no CSS comes with it. You position the overlay and size the dialog however you like. If components are too much structure, there's a useModal hook that hands back prop getters you spread onto your own markup.

How I built it: two decisions that mattered

The StrictMode trap I designed around from the start. A modal has to move focus into itself when it opens and restore focus when it closes. The tempting way to write that is: grab the content node in a ref callback, and do the focus/cleanup in a separate useEffect.

That pattern is a landmine under React 19's StrictMode — which every real app runs in development. StrictMode deliberately simulates an unmount/remount to flush out impure effects. During that dance, the useEffect cleanup fires (restoring focus, unlocking scroll) but the ref callback doesn't necessarily re-fire the way you assumed, and your setup quietly never runs again. It won't show up in a jsdom test, because jsdom doesn't simulate the double-invoke. It shows up as a modal that "works in tests" and breaks the moment you wrap your app in <StrictMode>.

So I tracked the content node in state instead of a bare ref, and put the entire lifecycle — initial focus, focus return, scroll lock, the Escape listener — inside useEffects keyed on that node and the open flag:

const [contentNode, setContentNode] = useState<HTMLElement | null>(null);
const contentRef = useCallback((n: HTMLElement | null) => setContentNode(n), []);

useEffect(() => {
  if (!open || !contentNode) return;
  const toRestore = triggerRef.current;
  focusFirst(contentNode);
  return () => toRestore?.focus(); // StrictMode re-runs setup after this, safely
}, [open, contentNode, returnFocus]);

Because setup and teardown live together in the same effect, StrictMode can tear down and rebuild as many times as it likes and the modal always ends up in the right state. I added an explicit StrictMode test so it can never regress.

Two small tricks for "obvious" behavior. Backdrop-click-to-close sounds easy until a click inside the dialog bubbles up and closes it. The fix is one comparison: the overlay only closes when event.target === event.currentTarget — i.e. the click landed on the overlay itself, not on a child. And for ARIA, the content only advertises aria-labelledby / aria-describedby when a ModalTitle / ModalDescription is actually rendered. Each one registers itself on mount, so you never get a dialog pointing aria-labelledby at an id that doesn't exist.

Try it

pnpm add headless-modal

It works with React 18 and 19, has no runtime dependencies, and is covered by tests at 100%. There's a live demo with a confirmation dialog, a focus-trapped form, an alertdialog that refuses to be dismissed by accident, controlled mode, and the bare hook — all on one page, so you can scroll once and see the whole surface.

This is the latest in a growing collection of headless-* primitives I'm building in public — small, accessibility-first React building blocks that each do one thing well. You can find the rest at github.com/kea0811?tab=repositories&q=headless.

What's next

A few things on the list: optional exit animations (keep the content mounted through a closing transition instead of unmounting immediately), a nested-modal-aware scroll lock that ref-counts so stacked dialogs don't fight over the body style, and RTL-aware focus order. If you try it and something feels off, I'd genuinely like to hear about it.


End of essay

About the author
Er An Khoo