## Modal — Overlay Backdrop, scroll lock, focus trap. Docs: https://www.interior.dev/docs/modal Reference: https://www.interior.dev/reference/modal License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/modal.json` Or run `bun add motion` and copy the source below. ### Usage ```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(null); return ( <> 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={ <> } >

Four seats and two production projects are attached to this plan.

); } ``` ### 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. - `description` (`React.ReactNode`) — default: `undefined` 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. - `footer` (`React.ReactNode`) — default: `undefined` Action row on a hairline divider. Stays put while the body scrolls. - `initialFocusRef` (`React.RefObject`) — default: `first focusable element` Element focused on open. Point it at the safe action when the dialog is destructive. - `closeOnEscape` (`boolean`) — default: `true` Escape is answered by the topmost open dialog only. - `closeOnBackdrop` (`boolean`) — default: `true` Requires the press to both start and end outside the panel. - `lockScroll` (`boolean`) — default: `true` Holds a reference-counted lock on document scrolling for as long as any dialog is open. - `showClose` (`boolean`) — default: `true` Hides the corner close button for dialogs that demand an explicit choice. - `closeLabel` (`string`) — default: `"Close dialog"` Accessible name for the close button. - `container` (`HTMLElement | null`) — default: `document.body` Portal target. Pass null to render nothing; pass an element to scope the overlay and the inert treatment to it. - `maxWidth` (`number`) — default: `440` Panel width cap in pixels. - `maxHeight` (`string`) — default: `"min(78vh, 620px)"` Panel height cap. The body scrolls once content passes it. - `className` (`string`) — default: `""` Appended last to the panel. ### Behavior notes - The scrollbar's width is measured and added back as body padding for as long as the lock is held, so the page behind does not jump sideways when the dialog opens or closes. - The scroll lock is reference counted and the Escape stack is ordered, so a dialog opened from inside another one does not hand scrolling back when only the inner one closes, and Escape reaches the topmost dialog only. - Tab and Shift-Tab wrap at the ends of the panel, a focusin guard pulls stray focus back, and on close focus returns to the element that opened the dialog with preventScroll, so the page never scrolls to a control the user cannot see. - Every sibling of the overlay is marked inert while the dialog is open and restored to its prior value on close, so a screen reader reads the dialog instead of the page underneath it, and the background cannot be reached by pointer or keyboard. - A backdrop press dismisses only when it both starts and ends outside the panel, so a text selection dragged past the edge of the dialog does not throw the dialog away. - The overlay is portalled out of the tree, so a transformed, filtered or overflow-hidden ancestor cannot re-anchor or clip the fixed layer; the panel height is capped and scrolls internally rather than animating toward an unbounded height; under prefers-reduced-motion the same dialog arrives at zero duration rather than being withheld. Behaviour is available on its own through the exported useModal hook. ### Source (`components/interior/modal.tsx`) ```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(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; container?: HTMLElement | null; }; export type ModalOverlayProps = { ref: React.RefObject; onPointerDown: (event: React.PointerEvent) => void; onClick: (event: React.MouseEvent) => void; }; export type ModalPanelProps = { ref: React.RefObject; 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(null); const overlayRef = useRef(null); const panelRef = useRef(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 = ( ); 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; 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( {open ? ( ) : null} , target, ); } ```