Chapter 2 of 4

Building an Accessible Modal

The parts a portal does not give you for free.

A portal solves the layout problem. Accessibility is a separate job, and a modal that skips it is unusable with a keyboard or a screen reader.

function Modal({ isOpen, onClose, title, children }) {
  const dialogRef = useRef(null);

  // Close on Escape
  useEffect(() => {
    if (!isOpen) return;
    function handleKeyDown(event) {
      if (event.key === "Escape") onClose();
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isOpen, onClose]);

  // Stop the page behind from scrolling
  useEffect(() => {
    if (!isOpen) return;
    const previous = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.body.style.overflow = previous; };
  }, [isOpen]);

  // Move focus into the dialog when it opens
  useEffect(() => {
    if (isOpen) dialogRef.current?.focus();
  }, [isOpen]);

  if (!isOpen) return null;

  return createPortal(
    <div className="overlay" onClick={onClose}>
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        tabIndex={-1}
        className="dialog"
        onClick={(event) => event.stopPropagation()}
      >
        <h2>{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

The checklist

  • role="dialog" and aria-modal="true" so assistive technology announces it correctly.
  • Focus moves into the dialog on open, and back to the trigger on close.
  • Focus is trapped inside while it is open - Tab must not reach the page behind.
  • Escape closes it.
  • The background does not scroll.
  • A click on the overlay closes; a click inside does not.

Rendering into a specific container

A dedicated container is easier to style and to reason about than document.body.
// index.html: <div id="modal-root"></div>
const modalRoot = document.getElementById("modal-root");

return createPortal(children, modalRoot);