javascript 124 lines · 3 tabs

Accessible React Modal with Portal, Focus Trap, and Escape-to-Close Hook

Shared by codesnips Aug 2026
3 tabs
import { useEffect, useRef } from 'react';

const TABBABLE = [
  'a[href]',
  'button:not([disabled])',
  'textarea:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  '[tabindex]:not([tabindex="-1"])'
].join(',');

export function useFocusTrap(active) {
  const containerRef = useRef(null);

  useEffect(() => {
    if (!active) return undefined;
    const container = containerRef.current;
    if (!container) return undefined;

    const previouslyFocused = document.activeElement;
    const tabbables = () => Array.from(container.querySelectorAll(TABBABLE));

    const first = tabbables()[0];
    if (first) first.focus();
    else container.focus();

    function onKeyDown(event) {
      if (event.key !== 'Tab') return;
      const nodes = tabbables();
      if (nodes.length === 0) return;
      const firstNode = nodes[0];
      const lastNode = nodes[nodes.length - 1];

      if (event.shiftKey && document.activeElement === firstNode) {
        event.preventDefault();
        lastNode.focus();
      } else if (!event.shiftKey && document.activeElement === lastNode) {
        event.preventDefault();
        firstNode.focus();
      }
    }

    container.addEventListener('keydown', onKeyDown);
    return () => {
      container.removeEventListener('keydown', onKeyDown);
      if (previouslyFocused && previouslyFocused.focus) {
        previouslyFocused.focus();
      }
    };
  }, [active]);

  return containerRef;
}
3 files · javascript Explain with highlit

This snippet builds an accessible modal dialog in React by composing three small, focused pieces: a reusable focus-trap hook, an Escape-key hook, and the Modal component that renders through a portal. Splitting the concerns keeps each hook testable in isolation and lets the component read as a declarative composition rather than a tangle of event listeners.

The useFocusTrap hook returns a ref that gets attached to the dialog container. When the modal opens it records document.activeElement so focus can be restored on close, then focuses the first tabbable element inside the container. Its keydown handler intercepts Tab and Shift+Tab, querying all tabbable descendants with a standard selector and wrapping focus from the last element back to the first (and vice versa). This is what prevents keyboard users from tabbing out of the dialog into the inert page behind it — a hard requirement for an accessible modal.

The useEscapeKey hook is intentionally tiny: it attaches a document-level keydown listener that invokes the supplied callback when Escape is pressed, and it is gated by an enabled flag so the listener is only active while the modal is open. Keeping the latest handler in a ref avoids re-subscribing on every render while still calling the current callback.

In Modal component, ReactDOM.createPortal renders the overlay into a #modal-root node outside the main app tree, which sidesteps z-index and overflow stacking problems from ancestor containers. The dialog wires up role="dialog", aria-modal="true", and aria-labelledby so assistive technology announces it correctly. An effect toggles document.body.style.overflow to lock background scrolling while open. Clicking the overlay closes the modal, but onMouseDown on the panel with stopPropagation prevents a drag that ends on the backdrop from triggering an accidental close.

The trade-off worth noting is that this hand-rolled trap does not make the rest of the page truly inert for screen readers; a fully robust solution would also apply aria-hidden or the inert attribute to sibling content. Even so, this pattern covers the common cases — restore focus, trap focus, close on Escape, close on overlay click — with minimal dependencies, and is a solid base to reach for when a full dialog library is overkill.


Related snips

Share this code

Here's the card — post it anywhere.

Accessible React Modal with Portal, Focus Trap, and Escape-to-Close Hook — share card
Link copied