interior[.]dev

Overlay·05.1

Modal

Backdrop, scroll lock, focus trap.

modal

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Usage

stats.tsx
"use client";

import { useRef, useState } from "react";
import { Modal } from "@/components/interior/modal";

export function BillingPanel({ cancelPlan }: { cancelPlan: () => void }) {
  const [confirming, setConfirming] = useState(false);
  const keepRef = useRef<HTMLButtonElement>(null);

  return (
    <>
      <button type="button" onClick={() => setConfirming(true)}>
        Cancel subscription
      </button>

      <Modal
        open={confirming}
        onClose={() => setConfirming(false)}
        initialFocusRef={keepRef}
        title="Cancel the Team plan?"
        description="Billing stops at the end of the period. Projects stay read-only after that."
        footer={
          <>
            <button ref={keepRef} type="button" onClick={() => setConfirming(false)}>
              Keep plan
            </button>
            <button
              type="button"
              onClick={() => {
                cancelPlan();
                setConfirming(false);
              }}
            >
              Cancel plan
            </button>
          </>
        }
      >
        <p>Four seats and two production projects are attached to this plan.</p>
      </Modal>
    </>
  );
}

Source

components/interior/modal.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

const EASE = [0.23, 1, 0.32, 1] as const;

const LEAVE = [0.4, 0, 1, 1] as const;

const SURFACE = { type: "spring", stiffness: 420, damping: 36, mass: 0.9 } as const;

const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? useEffect : useLayoutEffect;

const FOCUSABLE = [
  "a[href]",
  "area[href]",
  "button:not([disabled])",
  "input:not([disabled]):not([type='hidden'])",
  "select:not([disabled])",
  "textarea:not([disabled])",
  "iframe",
  "summary",
  "[contenteditable='true']",
  "[tabindex]:not([tabindex='-1'])",
].join(",");

function focusableWithin(root: HTMLElement): HTMLElement[] {
  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
    (el) =>
      el.tabIndex !== -1 &&
      !el.hasAttribute("inert") &&
      el.getAttribute("aria-hidden") !== "true" &&
      el.getClientRects().length > 0,
  );
}

let locks = 0;
let releaseLock: (() => void) | null = null;

function lockDocumentScroll() {
  locks += 1;
  if (locks > 1) return;

  const body = document.body;
  const gap = window.innerWidth - document.documentElement.clientWidth;
  const overflow = body.style.overflow;
  const paddingRight = body.style.paddingRight;
  const base = Number.parseFloat(window.getComputedStyle(body).paddingRight);

  body.style.overflow = "hidden";
  if (gap > 0) {
    body.style.paddingRight = `${(Number.isFinite(base) ? base : 0) + gap}px`;
  }

  releaseLock = () => {
    body.style.overflow = overflow;
    body.style.paddingRight = paddingRight;
  };
}

function unlockDocumentScroll() {
  locks = Math.max(0, locks - 1);
  if (locks > 0) return;
  releaseLock?.();
  releaseLock = null;
}

const stack: object[] = [];

export type UseModalOptions = {
  open: boolean;
  onClose: () => void;
  closeOnEscape?: boolean;
  closeOnBackdrop?: boolean;
  lockScroll?: boolean;
  initialFocusRef?: React.RefObject<HTMLElement | null>;
  container?: HTMLElement | null;
};

export type ModalOverlayProps = {
  ref: React.RefObject<HTMLDivElement | null>;
  onPointerDown: (event: React.PointerEvent) => void;
  onClick: (event: React.MouseEvent) => void;
};

export type ModalPanelProps = {
  ref: React.RefObject<HTMLDivElement | null>;
  role: "dialog";
  "aria-modal": true;
  "aria-labelledby": string;
  tabIndex: -1;
  onKeyDown: (event: React.KeyboardEvent) => void;
};

export type UseModalResult = {
  target: HTMLElement | null;
  titleId: string;
  descriptionId: string;
  overlayProps: ModalOverlayProps;
  panelProps: ModalPanelProps;
  close: () => void;
};

export function useModal({
  open,
  onClose,
  closeOnEscape = true,
  closeOnBackdrop = true,
  lockScroll = true,
  initialFocusRef,
  container,
}: UseModalOptions): UseModalResult {
  const [target, setTarget] = useState<HTMLElement | null>(null);

  const overlayRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const downedOutside = useRef(false);

  const baseId = useId();
  const titleId = `${baseId}-title`;
  const descriptionId = `${baseId}-description`;

  const latest = useRef({ onClose, closeOnEscape, closeOnBackdrop, initialFocusRef });
  latest.current = { onClose, closeOnEscape, closeOnBackdrop, initialFocusRef };

  const close = useCallback(() => latest.current.onClose(), []);

  useEffect(() => {
    setTarget(container === undefined ? document.body : container);
  }, [container]);

  useIsomorphicLayoutEffect(() => {
    if (!open || !lockScroll) return;
    lockDocumentScroll();
    return () => unlockDocumentScroll();
  }, [open, lockScroll]);

  useEffect(() => {
    if (!open || !target) return;
    const overlay = overlayRef.current;
    const parent = overlay?.parentElement;
    if (!overlay || !parent) return;

    const changed: Array<[Element, string | null]> = [];
    for (const child of Array.from(parent.children)) {
      if (child === overlay) continue;
      changed.push([child, child.getAttribute("inert")]);
      child.setAttribute("inert", "");
    }

    return () => {
      for (const [child, previous] of changed) {
        if (previous === null) child.removeAttribute("inert");
        else child.setAttribute("inert", previous);
      }
    };
  }, [open, target]);

  useEffect(() => {
    if (!open) return;
    const token = {};
    stack.push(token);

    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      if (stack[stack.length - 1] !== token) return;
      if (!latest.current.closeOnEscape) return;
      event.preventDefault();
      event.stopPropagation();
      latest.current.onClose();
    };

    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      const index = stack.indexOf(token);
      if (index > -1) stack.splice(index, 1);
    };
  }, [open]);

  useEffect(() => {
    if (!open || !target) return;
    const onFocusIn = (event: FocusEvent) => {
      const panel = panelRef.current;
      const node = event.target as Node | null;
      if (!panel || !node || panel.contains(node)) return;
      panel.focus({ preventScroll: true });
    };
    document.addEventListener("focusin", onFocusIn);
    return () => document.removeEventListener("focusin", onFocusIn);
  }, [open, target]);

  useEffect(() => {
    if (!open || !target) return;
    const panel = panelRef.current;
    if (!panel) return;

    const previous =
      document.activeElement instanceof HTMLElement ? document.activeElement : null;
    const preferred = latest.current.initialFocusRef?.current;
    (preferred ?? focusableWithin(panel)[0] ?? panel).focus({ preventScroll: true });

    return () => {
      if (previous && previous.isConnected) previous.focus({ preventScroll: true });
    };
  }, [open, target]);

  const onKeyDown = useCallback((event: React.KeyboardEvent) => {
    if (event.key !== "Tab") return;
    const panel = panelRef.current;
    if (!panel) return;

    const items = focusableWithin(panel);
    if (items.length === 0) {
      event.preventDefault();
      panel.focus({ preventScroll: true });
      return;
    }

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

    if (event.shiftKey && (active === first || active === panel)) {
      event.preventDefault();
      last.focus({ preventScroll: true });
      return;
    }
    if (!event.shiftKey && active === last) {
      event.preventDefault();
      first.focus({ preventScroll: true });
    }
  }, []);

  const onPointerDown = useCallback((event: React.PointerEvent) => {
    const panel = panelRef.current;
    downedOutside.current = !panel?.contains(event.target as Node);
  }, []);

  const onClick = useCallback((event: React.MouseEvent) => {
    const panel = panelRef.current;
    if (!latest.current.closeOnBackdrop) return;
    if (panel?.contains(event.target as Node)) return;
    if (!downedOutside.current) return;
    downedOutside.current = false;
    latest.current.onClose();
  }, []);

  return {
    target,
    titleId,
    descriptionId,
    overlayProps: { ref: overlayRef, onPointerDown, onClick },
    panelProps: {
      ref: panelRef,
      role: "dialog",
      "aria-modal": true,
      "aria-labelledby": titleId,
      tabIndex: -1,
      onKeyDown,
    },
    close,
  };
}

const CLOSE_ICON = (
  <svg width="14" height="14" viewBox="0 0 256 256" fill="none" aria-hidden="true">
    <line
      x1="200"
      y1="56"
      x2="56"
      y2="200"
      stroke="currentColor"
      strokeWidth="16"
      strokeLinecap="round"
    />
    <line
      x1="200"
      y1="200"
      x2="56"
      y2="56"
      stroke="currentColor"
      strokeWidth="16"
      strokeLinecap="round"
    />
  </svg>
);

export type ModalProps = {
  open: boolean;
  onClose: () => void;
  title: React.ReactNode;
  description?: React.ReactNode;
  children?: React.ReactNode;
  footer?: React.ReactNode;
  closeLabel?: string;
  showClose?: boolean;
  closeOnEscape?: boolean;
  closeOnBackdrop?: boolean;
  lockScroll?: boolean;
  initialFocusRef?: React.RefObject<HTMLElement | null>;
  container?: HTMLElement | null;
  maxWidth?: number;
  maxHeight?: string;
  className?: string;
};

export function Modal({
  open,
  onClose,
  title,
  description,
  children,
  footer,
  closeLabel = "Close dialog",
  showClose = true,
  closeOnEscape = true,
  closeOnBackdrop = true,
  lockScroll = true,
  initialFocusRef,
  container,
  maxWidth = 440,
  maxHeight = "min(78vh, 620px)",
  className = "",
}: ModalProps) {
  const reduced = useReducedMotion();

  const { target, titleId, descriptionId, overlayProps, panelProps } = useModal({
    open,
    onClose,
    closeOnEscape,
    closeOnBackdrop,
    lockScroll,
    initialFocusRef,
    container,
  });

  const variants = useMemo(() => {
    if (reduced) {
      return {
        backdrop: {
          closed: { opacity: 0 },
          open: { opacity: 1, transition: { duration: 0 } },
          gone: { opacity: 0, transition: { duration: 0 } },
        },
        panel: {
          closed: { opacity: 0 },
          open: { opacity: 1, transition: { duration: 0 } },
          gone: { opacity: 0, transition: { duration: 0 } },
        },
      };
    }
    return {
      backdrop: {
        closed: { opacity: 0 },
        open: { opacity: 1, transition: { duration: 0.2, ease: EASE } },
        gone: { opacity: 0, transition: { duration: 0.15, ease: LEAVE } },
      },
      panel: {
        closed: { opacity: 0, scale: 0.96, y: 12 },
        open: {
          opacity: 1,
          scale: 1,
          y: 0,
          transition: { ...SURFACE, opacity: { duration: 0.16, ease: EASE } },
        },

        gone: {
          opacity: 0,
          scale: 0.98,
          y: 6,
          transition: { duration: 0.15, ease: LEAVE },
        },
      },
    };
  }, [reduced]);

  if (!target) return null;

  return createPortal(
    <AnimatePresence>
      {open ? (
        <motion.div
          key="modal"
          {...overlayProps}
          initial="closed"
          animate="open"
          exit="gone"
          variants={{ closed: {}, open: {}, gone: {} }}
          className="fixed inset-0 z-50 grid place-items-center p-4 sm:p-6"
        >
          <motion.div
            aria-hidden="true"
            variants={variants.backdrop}
            style={{ touchAction: "none" }}
            className="absolute inset-0 bg-stone-900/40 dark:bg-black/65"
          />
          <motion.div
            {...panelProps}
            aria-describedby={description ? descriptionId : undefined}
            variants={variants.panel}
            style={{ maxWidth, maxHeight }}
            className={`relative flex w-full flex-col overflow-hidden rounded-[14px] border border-stone-200 bg-white text-stone-700 shadow-[0_28px_56px_-24px_rgba(24,22,20,0.45)] outline-none dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 ${className}`}
          >
            <div className="flex shrink-0 items-start gap-3 px-4 pb-3 pt-4">
              <div className="min-w-0 flex-1">
                <h2
                  id={titleId}
                  className="text-[15px] font-medium tracking-[-0.01em] text-stone-800 dark:text-stone-100"
                >
                  {title}
                </h2>
                {description ? (
                  <p
                    id={descriptionId}
                    className="mt-1 text-[12.5px] leading-relaxed text-stone-500 dark:text-stone-400"
                  >
                    {description}
                  </p>
                ) : null}
              </div>

              {showClose ? (
                <button
                  type="button"
                  onClick={onClose}
                  aria-label={closeLabel}
                  className="-mr-1 -mt-1 grid size-7 shrink-0 place-items-center rounded-[7px] text-stone-400 outline-none transition-colors duration-150 hover:bg-stone-100 hover:text-stone-700 focus-visible:bg-[#4568FF]/[0.06] focus-visible:text-stone-700 focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:text-stone-500 dark:hover:bg-white/10 dark:hover:text-stone-100 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:text-stone-100 dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
                >
                  {CLOSE_ICON}
                </button>
              ) : null}
            </div>

            {children ? (
              <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4 text-[13px] leading-relaxed">
                {children}
              </div>
            ) : null}

            {footer ? (
              <div className="flex shrink-0 items-center justify-end gap-2 border-t border-stone-200 px-4 py-3 dark:border-white/[0.16]">
                {footer}
              </div>
            ) : null}
          </motion.div>
        </motion.div>
      ) : null}
    </AnimatePresence>,
    target,
  );
}

Props

open
boolean

Controlled visibility. The overlay mounts and unmounts with it; exit runs before removal.

onClose
() => void

Called by Escape, the backdrop, and the close button. The component never closes itself.

title
React.ReactNode

Rendered as the h2 the dialog is labelled by.

descriptionundefined
React.ReactNode

Optional. Present only when set, and only then is aria-describedby applied.

children
React.ReactNode

Body content. Scrolls inside the capped panel rather than growing it.

footerundefined
React.ReactNode

Action row on a hairline divider. Stays put while the body scrolls.

initialFocusReffirst focusable element
React.RefObject<HTMLElement | null>

Element focused on open. Point it at the safe action when the dialog is destructive.

closeOnEscapetrue
boolean

Escape is answered by the topmost open dialog only.

closeOnBackdroptrue
boolean

Requires the press to both start and end outside the panel.

lockScrolltrue
boolean

Holds a reference-counted lock on document scrolling for as long as any dialog is open.

showClosetrue
boolean

Hides the corner close button for dialogs that demand an explicit choice.

closeLabel"Close dialog"
string

Accessible name for the close button.

containerdocument.body
HTMLElement | null

Portal target. Pass null to render nothing; pass an element to scope the overlay and the inert treatment to it.

maxWidth440
number

Panel width cap in pixels.

maxHeight"min(78vh, 620px)"
string

Panel height cap. The body scrolls once content passes it.

className""
string

Appended last to the panel.