{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dialog","type":"registry:component","title":"Dialog","description":"Opens a modal over the page, with a confirmation that stacks on top of it.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/dialog.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useIsPresent } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst OPEN = { duration: 0.2, ease: EASE_OUT };\n// Leaving is quicker than arriving: once dismissed, it should get out of the way.\nconst CLOSE = { duration: 0.15, ease: EASE_OUT };\n\nconst FOCUSABLE =\n  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n// Counted, so a nested dialog closing doesn't unlock the page under its parent.\nlet scrollLocks = 0;\nlet savedBody = { overflow: \"\", paddingRight: \"\" };\n\nfunction lockScroll() {\n  if (scrollLocks++ > 0) return;\n  const body = document.body;\n  // Hiding overflow removes the scrollbar; padding by its width keeps the\n  // page from sliding sideways under the backdrop.\n  const gap = window.innerWidth - document.documentElement.clientWidth;\n  const padding = parseFloat(getComputedStyle(body).paddingRight) || 0;\n  savedBody = {\n    overflow: body.style.overflow,\n    paddingRight: body.style.paddingRight,\n  };\n  body.style.overflow = \"hidden\";\n  if (gap > 0) body.style.paddingRight = `${padding + gap}px`;\n}\n\nfunction unlockScroll() {\n  if (--scrollLocks > 0) return;\n  document.body.style.overflow = savedBody.overflow;\n  document.body.style.paddingRight = savedBody.paddingRight;\n}\n\nconst subscribeNoop = () => () => {};\n// The portal needs document.body, which only exists on the client.\nfunction useIsClient() {\n  return useSyncExternalStore(\n    subscribeNoop,\n    () => true,\n    () => false,\n  );\n}\n\ntype Parent = {\n  setChildOpen: (open: boolean) => void;\n  openRef: React.RefObject<boolean>;\n};\nconst ParentContext = createContext<Parent | null>(null);\n\nexport function Dialog({\n  open,\n  onOpenChange,\n  title,\n  description,\n  role = \"dialog\",\n  className,\n  children,\n}: {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  /** \"alertdialog\" for confirmations that interrupt with a question. */\n  role?: \"dialog\" | \"alertdialog\";\n  className?: string;\n  children?: React.ReactNode;\n}) {\n  const isClient = useIsClient();\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const parent = useContext(ParentContext);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const [childOpen, setChildOpenState] = useState(false);\n  const childOpenRef = useRef(false);\n  const openRef = useRef(open);\n\n  const setChildOpen = useCallback((next: boolean) => {\n    childOpenRef.current = next;\n    setChildOpenState(next);\n  }, []);\n  const context = useMemo(() => ({ setChildOpen, openRef }), [setChildOpen]);\n\n  // A layout effect, so it's current before the passive cleanups below read it.\n  useLayoutEffect(() => {\n    openRef.current = open;\n  }, [open]);\n\n  useEffect(() => {\n    if (!open || !parent) return;\n    parent.setChildOpen(true);\n    return () => parent.setChildOpen(false);\n  }, [open, parent]);\n\n  useEffect(() => {\n    if (!open) return;\n    const previous = document.activeElement as HTMLElement | null;\n    const panel = panelRef.current;\n    const initial =\n      panel?.querySelector<HTMLElement>(\"[data-autofocus]\") ??\n      panel?.querySelector<HTMLElement>(FOCUSABLE) ??\n      panel;\n    initial?.focus({ preventScroll: true });\n    lockScroll();\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      // A nested dialog on top owns the keyboard until it closes.\n      if (childOpenRef.current || e.key !== \"Escape\") return;\n      e.preventDefault();\n      onOpenChange(false);\n    };\n    // Focus that escapes anyway (screen reader navigation, a click that lands\n    // on the page behind) is pulled back in.\n    const onFocusIn = (e: FocusEvent) => {\n      if (childOpenRef.current || !panel) return;\n      if (!panel.contains(e.target as Node))\n        panel.focus({ preventScroll: true });\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    document.addEventListener(\"focusin\", onFocusIn);\n    return () => {\n      document.removeEventListener(\"keydown\", onKeyDown);\n      document.removeEventListener(\"focusin\", onFocusIn);\n      unlockScroll();\n      // If the parent closed in the same moment, it hands focus back to its\n      // own trigger instead; this one sits inside a panel that's leaving.\n      if (parent && !parent.openRef.current) return;\n      const restore = () => previous?.focus({ preventScroll: true });\n      // The parent is still inert until it re-renders, and inert elements\n      // refuse focus, so wait a frame for it to come back.\n      if (previous?.closest(\"[inert]\")) requestAnimationFrame(restore);\n      else restore();\n    };\n  }, [open, onOpenChange, parent]);\n\n  if (!isClient) return null;\n\n  return createPortal(\n    <AnimatePresence>\n      {open && (\n        <Layer\n          key=\"layer\"\n          panelRef={panelRef}\n          id={id}\n          role={role}\n          title={title}\n          description={description}\n          receded={childOpen}\n          reduceMotion={!!reduceMotion}\n          onClose={() => onOpenChange(false)}\n          className={className}\n        >\n          <ParentContext.Provider value={context}>\n            {children}\n          </ParentContext.Provider>\n        </Layer>\n      )}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\nfunction Layer({\n  panelRef,\n  id,\n  role,\n  title,\n  description,\n  receded,\n  reduceMotion,\n  onClose,\n  className,\n  children,\n}: {\n  panelRef: React.RefObject<HTMLDivElement | null>;\n  id: string;\n  role: \"dialog\" | \"alertdialog\";\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  receded: boolean;\n  reduceMotion: boolean;\n  onClose: () => void;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  // While it animates out, clicks and focus go straight through to the page,\n  // so the exit never holds up the next interaction.\n  const isPresent = useIsPresent();\n  // Only a press that starts on the backdrop closes, so dragging a text\n  // selection out of the panel doesn't dismiss it.\n  const pressedBackdrop = useRef(false);\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (e.key !== \"Tab\") return;\n    const items = [\n      ...e.currentTarget.querySelectorAll<HTMLElement>(FOCUSABLE),\n    ].filter((el) => el.getClientRects().length > 0);\n    if (!items.length) {\n      e.preventDefault();\n      return;\n    }\n    const first = items[0];\n    const last = items[items.length - 1];\n    const active = document.activeElement;\n    if (e.shiftKey && (active === first || active === e.currentTarget)) {\n      e.preventDefault();\n      last.focus();\n    } else if (!e.shiftKey && active === last) {\n      e.preventDefault();\n      first.focus();\n    }\n  };\n\n  return (\n    <div\n      className={cn(\"fixed inset-0 z-50\", !isPresent && \"pointer-events-none\")}\n    >\n      {/* Token-only dim: a grey wash in light, near black in dark. A nested\n          dialog's backdrop stacks on this one, which is what dims the parent. */}\n      <motion.div\n        aria-hidden\n        className=\"absolute inset-0 bg-foreground/15 dark:bg-background/70\"\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1, transition: OPEN }}\n        exit={{ opacity: 0, transition: CLOSE }}\n        onPointerDown={(e) => {\n          pressedBackdrop.current = e.target === e.currentTarget;\n        }}\n        onClick={(e) => {\n          if (pressedBackdrop.current && e.target === e.currentTarget)\n            onClose();\n          pressedBackdrop.current = false;\n        }}\n      />\n      <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center p-4\">\n        <motion.div\n          ref={panelRef}\n          role={role}\n          aria-modal=\"true\"\n          aria-labelledby={`${id}-title`}\n          aria-describedby={description ? `${id}-description` : undefined}\n          tabIndex={-1}\n          // Behind a nested dialog it stays visible but unreachable, until\n          // it's back in front.\n          inert={receded || !isPresent}\n          onKeyDown={onKeyDown}\n          // Modals scale from the center: they belong to the viewport, not\n          // to the button that opened them.\n          initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}\n          // A small step back while a nested dialog is on top, so the new one\n          // reads as in front of it.\n          animate={{\n            opacity: 1,\n            scale: receded && !reduceMotion ? 0.97 : 1,\n            transition: OPEN,\n          }}\n          // Softer than the entrance: a smaller shrink, and faster.\n          exit={\n            reduceMotion\n              ? { opacity: 0, transition: CLOSE }\n              : { opacity: 0, scale: 0.98, transition: CLOSE }\n          }\n          className={cn(\n            \"pointer-events-auto w-[440px] max-w-full rounded-2xl bg-background p-6 shadow-raised outline-hidden\",\n            className,\n          )}\n        >\n          <h2\n            id={`${id}-title`}\n            className=\"text-lg font-semibold tracking-tight text-foreground\"\n          >\n            {title}\n          </h2>\n          {description && (\n            <p\n              id={`${id}-description`}\n              className=\"mt-1.5 text-sm text-pretty text-muted-foreground\"\n            >\n              {description}\n            </p>\n          )}\n          {children}\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n\nconst button =\n  \"flex h-10 touch-manipulation items-center justify-center rounded-full px-4 text-sm font-medium outline-hidden transition-[scale,background-color] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[background-color]\";\nconst secondary = cn(button, \"bg-muted text-foreground hover:bg-border\");\nconst primary = cn(\n  button,\n  \"bg-foreground text-background hover:bg-foreground/85\",\n);\n\nexport default function DialogDemo() {\n  const inputId = useId();\n  const [open, setOpen] = useState(false);\n  const [confirming, setConfirming] = useState(false);\n  const [name, setName] = useState(\"northwind-web\");\n  const [draft, setDraft] = useState(name);\n  const [deleted, setDeleted] = useState(false);\n\n  return (\n    // 12px padding + the 20px button radius = 32px, so the corners nest.\n    <div className=\"flex w-[min(400px,100%)] items-center gap-3 rounded-[32px] bg-background py-3 pr-3 pl-6 shadow-raised\">\n      <div className=\"min-w-0 flex-1\">\n        <p className=\"truncate text-[15px] font-medium text-foreground\">\n          {name}\n        </p>\n        <p className=\"mt-0.5 text-sm text-muted-foreground\">\n          {deleted ? \"Scheduled for deletion\" : \"Deployed 2 hours ago\"}\n        </p>\n      </div>\n      <button\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        onClick={() => {\n          setDraft(name);\n          setOpen(true);\n        }}\n        className={secondary}\n      >\n        Settings\n      </button>\n\n      <Dialog\n        open={open}\n        onOpenChange={setOpen}\n        title=\"Project settings\"\n        description=\"Changes apply to every environment of this project.\"\n      >\n        <form\n          className=\"mt-5\"\n          onSubmit={(e) => {\n            e.preventDefault();\n            if (draft.trim()) setName(draft.trim());\n            setOpen(false);\n          }}\n        >\n          <label\n            htmlFor={inputId}\n            className=\"text-sm font-medium text-foreground\"\n          >\n            Project name\n          </label>\n          <input\n            id={inputId}\n            data-autofocus\n            value={draft}\n            onChange={(e) => setDraft(e.target.value)}\n            spellCheck={false}\n            autoComplete=\"off\"\n            className=\"mt-1.5 h-10 w-full rounded-lg bg-muted px-3 text-[15px] text-foreground outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\"\n          />\n\n          {/* 10px padding + the 18px button radius = 28px. */}\n          <div className=\"mt-5 flex items-center gap-3 rounded-[28px] border border-border py-2.5 pr-2.5 pl-5\">\n            <p className=\"min-w-0 flex-1 text-sm text-pretty text-muted-foreground\">\n              Delete this project and all of its deployments.\n            </p>\n            <button\n              type=\"button\"\n              aria-haspopup=\"dialog\"\n              onClick={() => setConfirming(true)}\n              className={cn(\n                button,\n                \"h-9 shrink-0 bg-destructive/10 px-3.5 text-destructive hover:bg-destructive/15\",\n              )}\n            >\n              Delete project\n            </button>\n          </div>\n\n          <div className=\"mt-6 flex justify-end gap-2\">\n            <button\n              type=\"button\"\n              onClick={() => setOpen(false)}\n              className={secondary}\n            >\n              Cancel\n            </button>\n            <button type=\"submit\" className={primary}>\n              Save\n            </button>\n          </div>\n        </form>\n\n        <Dialog\n          open={confirming}\n          onOpenChange={setConfirming}\n          role=\"alertdialog\"\n          title={`Delete ${name}?`}\n          description=\"Its 24 deployments go with it. This can't be undone.\"\n          className=\"w-[360px]\"\n        >\n          <div className=\"mt-6 flex justify-end gap-2\">\n            {/* The safe choice takes focus, so a stray Enter can't delete. */}\n            <button\n              type=\"button\"\n              data-autofocus\n              onClick={() => setConfirming(false)}\n              className={secondary}\n            >\n              Cancel\n            </button>\n            <button\n              type=\"button\"\n              onClick={() => {\n                setDeleted(true);\n                setConfirming(false);\n                setOpen(false);\n              }}\n              className={cn(button, \"bg-destructive text-white hover:bg-destructive/90\")}\n            >\n              Delete\n            </button>\n          </div>\n        </Dialog>\n      </Dialog>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/dialog"}