Overlay

Modal

Accessible modal dialog with a focus trap and portal

Preview

size
animation
title
fullScreen
persistent
footer

Code

import css from "./Modal.module.css";
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";

import { XIcon } from "@phosphor-icons/react/dist/csr/X";

type Size = "sm" | "md" | "lg";
type Animation = "zoom" | "scale";

interface ModalProps {
  open: boolean;
  size: Size;
  animation: Animation;
  fullScreen?: boolean;
  title?: string;
  "aria-label"?: string;
  closeAriaLabel: string;
  persistent?: boolean;
  footer?: React.ReactNode;
  children: React.ReactNode;
  onClose: () => void;
}

type Phase = "enter" | "shown" | "leave";

// MUST match the CSS `transition` (0.3s) — an invisible coupling.
const DURATION = 300;

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

function getFocusables(container: HTMLElement): HTMLElement[] {
  return Array.from(
    container.querySelectorAll<HTMLElement>(FOCUSABLE),
  ).filter((el) => el.getClientRects().length > 0);
}

function trapFocus(ev: KeyboardEvent, container: HTMLElement | null) {
  if (!container) return;

  const focusables = getFocusables(container);
  if (focusables.length === 0) {
    ev.preventDefault();
    container.focus();
    return;
  }

  const first = focusables[0];
  const last = focusables[focusables.length - 1];
  const active = document.activeElement;

  if (!container.contains(active)) {
    ev.preventDefault();
    first.focus();
    return;
  }

  if (ev.shiftKey && active === first) {
    ev.preventDefault();
    last.focus();
  } else if (!ev.shiftKey && active === last) {
    ev.preventDefault();
    first.focus();
  }
}

export default function Modal({
  open,
  size,
  animation,
  fullScreen,
  title,
  "aria-label": ariaLabel,
  closeAriaLabel,
  persistent,
  footer,
  children,
  onClose,
}: ModalProps) {
  // `mounted` lags behind `open` so the modal survives its exit animation.
  const [mounted, setMounted] = useState(open);
  const [phase, setPhase] = useState<Phase>("enter");

  const card = useRef<HTMLDivElement>(null);
  const overlay = useRef<HTMLDivElement>(null);
  const restoreFocus = useRef<HTMLElement | null>(null);
  const pointerDownOnOverlay = useRef(false);
  const titleId = useId();

  // Pointer guard: a selection dragged from inside and released here must not close.
  const onPointerDown = (ev: React.PointerEvent) => {
    pointerDownOnOverlay.current = ev.target === ev.currentTarget;
  };

  const onOverlayClick = (ev: React.MouseEvent) => {
    if (
      !persistent &&
      pointerDownOnOverlay.current &&
      ev.target === ev.currentTarget
    ) {
      onClose();
    }
  };

  useEffect(() => {
    if (open) setMounted(true);
  }, [open]);

  // The rAF gives the browser a "from" state to animate out of.
  useEffect(() => {
    if (!mounted) return;

    if (open) {
      setPhase("enter");
      const raf = requestAnimationFrame(() => setPhase("shown"));
      return () => cancelAnimationFrame(raf);
    }

    setPhase("leave");
    const reduce = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;
    const timer = setTimeout(() => setMounted(false), reduce ? 0 : DURATION);
    return () => clearTimeout(timer);
  }, [open, mounted]);

  useEffect(() => {
    if (!mounted) return;

    restoreFocus.current = document.activeElement as HTMLElement;

    // Fixed elements escape the body padding; the width also ships as --scrollbar-lock so they pad
    // themselves.
    const scrollbar = window.innerWidth - document.documentElement.clientWidth;
    const prevOverflow = document.body.style.overflow;
    const prevPad = document.body.style.paddingRight;

    document.body.style.overflow = "hidden";
    if (scrollbar > 0) {
      document.body.style.paddingRight = `${scrollbar}px`;
      document.documentElement.style.setProperty(
        "--scrollbar-lock",
        `${scrollbar}px`,
      );
    }

    const background = Array.from(document.body.children).filter(
      (el) => el !== overlay.current,
    );
    background.forEach((el) => el.setAttribute("inert", ""));

    const dialog = card.current;
    if (dialog) (getFocusables(dialog)[0] ?? dialog).focus();

    return () => {
      document.body.style.overflow = prevOverflow;
      document.body.style.paddingRight = prevPad;
      document.documentElement.style.removeProperty("--scrollbar-lock");
      background.forEach((el) => el.removeAttribute("inert"));
      restoreFocus.current?.focus?.();
    };
  }, [mounted]);

  // Capture phase, so the key is intercepted before the children see it.
  useEffect(() => {
    if (!mounted) return;

    const onKeyDown = (ev: KeyboardEvent) => {
      if (ev.key === "Escape" && !persistent) {
        ev.stopPropagation();
        onClose();
        return;
      }
      if (ev.key === "Tab") trapFocus(ev, card.current);
    };

    document.addEventListener("keydown", onKeyDown, true);
    return () => document.removeEventListener("keydown", onKeyDown, true);
  }, [mounted, persistent, onClose]);

  if (!mounted) return null;

  return createPortal(
    <div
      ref={overlay}
      className={`
        ${css.overlay}
        ${css[phase]}
        ${fullScreen ? css.fullscreen : ""}
      `}
      onPointerDown={onPointerDown}
      onClick={onOverlayClick}
    >
      <div
        className={`
          ${css.overlay_content}
          ${css[animation]}
          ${css[phase]}
          ${fullScreen ? css.fullscreen : css[size]}
        `}
      >
        <div
          ref={card}
          className={css.content_card}
          role="dialog"
          aria-modal="true"
          aria-labelledby={title ? titleId : undefined}
          aria-label={!title ? ariaLabel : undefined}
          tabIndex={-1}
        >
          {title && (
            <header className={css.card_head}>
              <h2 id={titleId} className={css.head_title}>
                {title}
              </h2>
              {!persistent && (
                <button
                  type="button"
                  className={css.head_close}
                  onClick={onClose}
                  aria-label={closeAriaLabel}
                >
                  <XIcon weight="bold" />
                </button>
              )}
            </header>
          )}

          {!title && !persistent && (
            <div className={css.card_close}>
              <button
                type="button"
                className={css.close_button}
                onClick={onClose}
                aria-label={closeAriaLabel}
              >
                <XIcon weight="bold" />
              </button>
            </div>
          )}

          <div className={css.card_body}>{children}</div>

          {footer && <footer className={css.card_foot}>{footer}</footer>}
        </div>
      </div>
    </div>,
    document.body,
  );
}

Props

PropTypeRequiredDescription
openbooleanrequiredVisibility (controlled); the parent owns the state.
onClose() => voidrequiredCallback when the modal asks to close (Esc, outside click, X).
size"sm" | "md" | "lg"requiredMaximum width of the card.
animation"zoom" | "scale"requiredEnter and exit animation.
fullScreenboolean—Fills the whole screen.
titlestring—Dialog title (aria-labelledby); without it, the name comes from aria-label.
aria-labelstring—Dialog name when there is no title; without either, a screen reader only announces "dialog".
closeAriaLabelstringrequiredName of the close button for screen readers.
persistentboolean—Does not close on Esc or outside click and hides the X; the content must offer the way out.
footerReact.ReactNode—Actions area at the bottom.
childrenReact.ReactNoderequiredBody content.

Imperative API

Optional sugar over the declarative <Modal>. Wrap your app in ModalProvider and open it with a call.

result: —

Props

PropTypeRequiredDescription
size"sm" | "md" | "lg"requiredMaximum width of the card.
animation"zoom" | "scale"requiredEnter and exit animation.
closeAriaLabelstringrequiredName of the close button for screen readers.
titlestring—Dialog title (aria-labelledby); without it, the name comes from aria-label.
aria-labelstring—Dialog name when there is no title; without either, a screen reader only announces "dialog".
fullScreenboolean—Fills the whole screen.
persistentboolean—Does not close on Esc or outside click and hides the X; the content must offer the way out.
contentReactNode | (close: (result?: T) => void) => ReactNoderequiredBody content. As a function it receives close(result) to dismiss resolving a value.
footerReactNode | (close: (result?: T) => void) => ReactNode—Actions area at the bottom. Same function contract as content.
→ open()Promise<T | undefined>—Resolves with the value from close(value); Esc, backdrop or X resolve undefined. A second open() replaces the active one (resolves undefined).