{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "modal",
  "type": "registry:ui",
  "title": "Modal",
  "description": "Handles focus, backdrop, exit, and async confirmation as one flow.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "overlays-surfaces"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/modal/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Dialog, backdrop, and focus scope transition together",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Modal with a clear confirmation action",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/modal.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/modal.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\n\nconst LEAVE = [0.23, 1, 0.32, 1] as const;\n\nconst SURFACE = { type: \"spring\", stiffness: 420, damping: 36, mass: 0.9 } as const;\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nconst FOCUSABLE = [\n  \"a[href]\",\n  \"area[href]\",\n  \"button:not([disabled])\",\n  \"input:not([disabled]):not([type='hidden'])\",\n  \"select:not([disabled])\",\n  \"textarea:not([disabled])\",\n  \"iframe\",\n  \"summary\",\n  \"[contenteditable='true']\",\n  \"[tabindex]:not([tabindex='-1'])\",\n].join(\",\");\n\nfunction focusableWithin(root: HTMLElement): HTMLElement[] {\n  return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n    (el) =>\n      el.tabIndex !== -1 &&\n      !el.hasAttribute(\"inert\") &&\n      el.getAttribute(\"aria-hidden\") !== \"true\" &&\n      el.getClientRects().length > 0,\n  );\n}\n\nlet locks = 0;\nlet releaseLock: (() => void) | null = null;\n\nfunction lockDocumentScroll() {\n  locks += 1;\n  if (locks > 1) return;\n\n  const body = document.body;\n  const gap = window.innerWidth - document.documentElement.clientWidth;\n  const overflow = body.style.overflow;\n  const paddingRight = body.style.paddingRight;\n  const base = Number.parseFloat(window.getComputedStyle(body).paddingRight);\n\n  body.style.overflow = \"hidden\";\n  if (gap > 0) {\n    body.style.paddingRight = `${(Number.isFinite(base) ? base : 0) + gap}px`;\n  }\n\n  releaseLock = () => {\n    body.style.overflow = overflow;\n    body.style.paddingRight = paddingRight;\n  };\n}\n\nfunction unlockDocumentScroll() {\n  locks = Math.max(0, locks - 1);\n  if (locks > 0) return;\n  releaseLock?.();\n  releaseLock = null;\n}\n\nconst stack: object[] = [];\n\nexport type UseModalOptions = {\n  open: boolean;\n  onClose: () => void;\n  closeOnEscape?: boolean;\n  closeOnBackdrop?: boolean;\n  lockScroll?: boolean;\n  initialFocusRef?: React.RefObject<HTMLElement>;\n  container?: HTMLElement | null;\n};\n\nexport type ModalOverlayProps = {\n  ref: React.RefObject<HTMLDivElement>;\n  onPointerDown: (event: React.PointerEvent) => void;\n  onClick: (event: React.MouseEvent) => void;\n};\n\nexport type ModalPanelProps = {\n  ref: React.RefObject<HTMLDivElement>;\n  role: \"dialog\";\n  \"aria-modal\": true;\n  \"aria-labelledby\": string;\n  tabIndex: -1;\n  onKeyDown: (event: React.KeyboardEvent) => void;\n};\n\nexport type UseModalResult = {\n  target: HTMLElement | null;\n  titleId: string;\n  descriptionId: string;\n  overlayProps: ModalOverlayProps;\n  panelProps: ModalPanelProps;\n  close: () => void;\n};\n\nexport function useModal({\n  open,\n  onClose,\n  closeOnEscape = true,\n  closeOnBackdrop = true,\n  lockScroll = true,\n  initialFocusRef,\n  container,\n}: UseModalOptions): UseModalResult {\n  const [target, setTarget] = useState<HTMLElement | null>(null);\n\n  const overlayRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const downedOutside = useRef(false);\n\n  const baseId = useId();\n  const titleId = `${baseId}-title`;\n  const descriptionId = `${baseId}-description`;\n\n  const latest = useRef({ onClose, closeOnEscape, closeOnBackdrop, initialFocusRef });\n  latest.current = { onClose, closeOnEscape, closeOnBackdrop, initialFocusRef };\n\n  const close = useCallback(() => latest.current.onClose(), []);\n\n  useEffect(() => {\n    setTarget(container === undefined ? document.body : container);\n  }, [container]);\n\n  useIsomorphicLayoutEffect(() => {\n    if (!open || !lockScroll) return;\n    lockDocumentScroll();\n    return () => unlockDocumentScroll();\n  }, [open, lockScroll]);\n\n  useEffect(() => {\n    if (!open || !target) return;\n    const overlay = overlayRef.current;\n    const parent = overlay?.parentElement;\n    if (!overlay || !parent) return;\n\n    const changed: Array<[Element, string | null]> = [];\n    for (const child of Array.from(parent.children)) {\n      if (child === overlay) continue;\n      changed.push([child, child.getAttribute(\"inert\")]);\n      child.setAttribute(\"inert\", \"\");\n    }\n\n    return () => {\n      for (const [child, previous] of changed) {\n        if (previous === null) child.removeAttribute(\"inert\");\n        else child.setAttribute(\"inert\", previous);\n      }\n    };\n  }, [open, target]);\n\n  useEffect(() => {\n    if (!open) return;\n    const token = {};\n    stack.push(token);\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      if (stack[stack.length - 1] !== token) return;\n      if (!latest.current.closeOnEscape) return;\n      event.preventDefault();\n      event.stopPropagation();\n      latest.current.onClose();\n    };\n\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"keydown\", onKeyDown);\n      const index = stack.indexOf(token);\n      if (index > -1) stack.splice(index, 1);\n    };\n  }, [open]);\n\n  useEffect(() => {\n    if (!open || !target) return;\n    const onFocusIn = (event: FocusEvent) => {\n      const panel = panelRef.current;\n      const node = event.target as Node | null;\n      if (!panel || !node || panel.contains(node)) return;\n      panel.focus({ preventScroll: true });\n    };\n    document.addEventListener(\"focusin\", onFocusIn);\n    return () => document.removeEventListener(\"focusin\", onFocusIn);\n  }, [open, target]);\n\n  useEffect(() => {\n    if (!open || !target) return;\n    const panel = panelRef.current;\n    if (!panel) return;\n\n    const previous =\n      document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    const preferred = latest.current.initialFocusRef?.current;\n    (preferred ?? focusableWithin(panel)[0] ?? panel).focus({ preventScroll: true });\n\n    return () => {\n      if (previous && previous.isConnected) previous.focus({ preventScroll: true });\n    };\n  }, [open, target]);\n\n  const onKeyDown = useCallback((event: React.KeyboardEvent) => {\n    if (event.key !== \"Tab\") return;\n    const panel = panelRef.current;\n    if (!panel) return;\n\n    const items = focusableWithin(panel);\n    if (items.length === 0) {\n      event.preventDefault();\n      panel.focus({ preventScroll: true });\n      return;\n    }\n\n    const first = items[0];\n    const last = items[items.length - 1];\n    const active = document.activeElement;\n\n    if (event.shiftKey && (active === first || active === panel)) {\n      event.preventDefault();\n      last.focus({ preventScroll: true });\n      return;\n    }\n    if (!event.shiftKey && active === last) {\n      event.preventDefault();\n      first.focus({ preventScroll: true });\n    }\n  }, []);\n\n  const onPointerDown = useCallback((event: React.PointerEvent) => {\n    const panel = panelRef.current;\n    downedOutside.current = !panel?.contains(event.target as Node);\n  }, []);\n\n  const onClick = useCallback((event: React.MouseEvent) => {\n    const panel = panelRef.current;\n    if (!latest.current.closeOnBackdrop) return;\n    if (panel?.contains(event.target as Node)) return;\n    if (!downedOutside.current) return;\n    downedOutside.current = false;\n    latest.current.onClose();\n  }, []);\n\n  return {\n    target,\n    titleId,\n    descriptionId,\n    overlayProps: { ref: overlayRef, onPointerDown, onClick },\n    panelProps: {\n      ref: panelRef,\n      role: \"dialog\",\n      \"aria-modal\": true,\n      \"aria-labelledby\": titleId,\n      tabIndex: -1,\n      onKeyDown,\n    },\n    close,\n  };\n}\n\nconst CLOSE_ICON = (\n  <svg width=\"14\" height=\"14\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <line\n      x1=\"200\"\n      y1=\"56\"\n      x2=\"56\"\n      y2=\"200\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n    />\n    <line\n      x1=\"200\"\n      y1=\"200\"\n      x2=\"56\"\n      y2=\"56\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n    />\n  </svg>\n);\n\nexport type ModalProps = {\n  open: boolean;\n  onClose: () => void;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  children?: React.ReactNode;\n  footer?: React.ReactNode;\n  closeLabel?: string;\n  showClose?: boolean;\n  closeOnEscape?: boolean;\n  closeOnBackdrop?: boolean;\n  lockScroll?: boolean;\n  initialFocusRef?: React.RefObject<HTMLElement>;\n  container?: HTMLElement | null;\n  maxWidth?: number;\n  maxHeight?: string;\n  className?: string;\n};\n\nexport function Modal({\n  open,\n  onClose,\n  title,\n  description,\n  children,\n  footer,\n  closeLabel = \"Close dialog\",\n  showClose = true,\n  closeOnEscape = true,\n  closeOnBackdrop = true,\n  lockScroll = true,\n  initialFocusRef,\n  container,\n  maxWidth = 440,\n  maxHeight = \"min(78vh, 620px)\",\n  className = \"\",\n}: ModalProps) {\n  const reduced = useReducedMotion();\n\n  const { target, titleId, descriptionId, overlayProps, panelProps } = useModal({\n    open,\n    onClose,\n    closeOnEscape,\n    closeOnBackdrop,\n    lockScroll,\n    initialFocusRef,\n    container,\n  });\n\n  const variants = useMemo(() => {\n    if (reduced) {\n      return {\n        backdrop: {\n          closed: { opacity: 0 },\n          open: { opacity: 1, transition: { duration: 0 } },\n          gone: { opacity: 0, transition: { duration: 0 } },\n        },\n        panel: {\n          closed: { opacity: 0 },\n          open: { opacity: 1, transition: { duration: 0 } },\n          gone: { opacity: 0, transition: { duration: 0 } },\n        },\n      };\n    }\n    return {\n      backdrop: {\n        closed: { opacity: 0 },\n        open: { opacity: 1, transition: { duration: 0.2, ease: EASE } },\n        gone: { opacity: 0, transition: { duration: 0.15, ease: LEAVE } },\n      },\n      panel: {\n        closed: { opacity: 0, scale: 0.96, y: 12 },\n        open: {\n          opacity: 1,\n          scale: 1,\n          y: 0,\n          transition: { ...SURFACE, opacity: { duration: 0.16, ease: EASE } },\n        },\n\n        gone: {\n          opacity: 0,\n          scale: 0.98,\n          y: 6,\n          transition: { duration: 0.15, ease: LEAVE },\n        },\n      },\n    };\n  }, [reduced]);\n\n  if (!target) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {open ? (\n        <motion.div\n          key=\"modal\"\n          {...overlayProps}\n          initial=\"closed\"\n          animate=\"open\"\n          exit=\"gone\"\n          variants={{ closed: {}, open: {}, gone: {} }}\n          className=\"fixed inset-0 z-50 grid place-items-center p-4 sm:p-6\"\n        >\n          <motion.div\n            aria-hidden=\"true\"\n            variants={variants.backdrop}\n            style={{ touchAction: \"none\" }}\n            className=\"absolute inset-0 bg-stone-900/40 dark:bg-black/65\"\n          />\n          <motion.div\n            {...panelProps}\n            aria-describedby={description ? descriptionId : undefined}\n            variants={variants.panel}\n            style={{ maxWidth, maxHeight }}\n            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}`}\n          >\n            <div className=\"flex shrink-0 items-start gap-3 px-4 pb-3 pt-4\">\n              <div className=\"min-w-0 flex-1\">\n                <h2\n                  id={titleId}\n                  className=\"text-[15px] font-medium tracking-[-0.01em] text-stone-800 dark:text-stone-100\"\n                >\n                  {title}\n                </h2>\n                {description ? (\n                  <p\n                    id={descriptionId}\n                    className=\"mt-1 text-[12.5px] leading-relaxed text-stone-500 dark:text-stone-400\"\n                  >\n                    {description}\n                  </p>\n                ) : null}\n              </div>\n\n              {showClose ? (\n                <button\n                  type=\"button\"\n                  onClick={onClose}\n                  aria-label={closeLabel}\n                  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]\"\n                >\n                  {CLOSE_ICON}\n                </button>\n              ) : null}\n            </div>\n\n            {children ? (\n              <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4 text-[13px] leading-relaxed\">\n                {children}\n              </div>\n            ) : null}\n\n            {footer ? (\n              <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]\">\n                {footer}\n              </div>\n            ) : null}\n          </motion.div>\n        </motion.div>\n      ) : null}\n    </AnimatePresence>,\n    target,\n  );\n}\n"
    }
  ]
}
