{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dialog",
  "type": "registry:ui",
  "title": "Dialog",
  "description": "A focused surface for decisions that need attention.",
  "categories": [
    "disclosure"
  ],
  "dependencies": [
    "@radix-ui/react-dialog",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/dialog/dialog.tsx",
      "type": "registry:component",
      "target": "@components/xzero/dialog/dialog.tsx",
      "content": "\"use client\";\n\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { createContext, useCallback, useContext, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./dialog.module.css\";\n\n/** Mirrors the open state so the content can stay mounted while it animates out, and retarget mid-flight if it is reopened or closed early. */\nconst OpenContext = createContext<boolean | null>(null);\n\nexport function Dialog({ open: openProp, defaultOpen = false, onOpenChange, ...props }: ComponentPropsWithoutRef<typeof DialogPrimitive.Root>) {\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  const open = openProp ?? uncontrolled;\n  const setOpen = useCallback((next: boolean) => { if (openProp === undefined) setUncontrolled(next); onOpenChange?.(next); }, [openProp, onOpenChange]);\n  return <OpenContext.Provider value={open}><DialogPrimitive.Root {...props} open={open} onOpenChange={setOpen}/></OpenContext.Provider>;\n}\n\nexport const DialogTrigger = DialogPrimitive.Trigger;\nexport const DialogClose = DialogPrimitive.Close;\n\nexport interface DialogContentProps extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {\n  title: string;\n  description?: string;\n  children: ReactNode;\n}\n\nconst fade: Transition = { duration: motionTokens.duration.instant };\nconst leave: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\n\n/** When the title or description changes while open, the new copy rises in and the old copy leaves upward. */\nfunction SwapText({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  return <AnimatePresence mode=\"popLayout\" initial={false}>\n    <motion.span key={text} className={styles.swap} initial={reduced ? false : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{text}</motion.span>\n  </AnimatePresence>;\n}\n\nexport function DialogContent({ title, description, children, className, onPointerDownOutside, ...props }: DialogContentProps) {\n  const open = useContext(OpenContext);\n  const reduced = useReducedMotion();\n  // When the open state last changed. Radix waits for the click before treating a press as outside, and a press on the trigger\n  // while the dialog leaves reopens it first, so that press must not close it again.\n  const change = useRef({ open, at: 0 });\n  useLayoutEffect(() => { change.current = { open, at: performance.now() }; }, [open]);\n  const pressOutside: DialogContentProps[\"onPointerDownOutside\"] = event => {\n    onPointerDownOutside?.(event);\n    if (open !== null && (!change.current.open || event.detail.originalEvent.timeStamp < change.current.at)) event.preventDefault();\n  };\n  const classes = [styles.content, className].filter(Boolean).join(\" \");\n  const inner = <>\n    <div className={styles.header}><div><DialogPrimitive.Title className={styles.title}><SwapText text={title}/></DialogPrimitive.Title>{description ? <DialogPrimitive.Description className={styles.description}><SwapText text={description}/></DialogPrimitive.Description> : null}</div><DialogPrimitive.Close className={styles.close} aria-label=\"Close dialog\"><X size={16} strokeWidth={1.75} aria-hidden=\"true\"/></DialogPrimitive.Close></div>\n    <div className={styles.body}>{children}</div>\n  </>;\n  // Under a bare Radix root the open state is unknown here, so CSS keyframes keyed off data-state animate the layers instead.\n  if (open === null) return <DialogPrimitive.Portal>\n    <DialogPrimitive.Overlay className={`${styles.overlay} ${styles.keyframes}`}/>\n    <DialogPrimitive.Content {...props} onPointerDownOutside={pressOutside} className={`${classes} ${styles.keyframes}`}>{inner}</DialogPrimitive.Content>\n  </DialogPrimitive.Portal>;\n  // The overlay fades while the dialog rises 8px and scales up on a spring. Closing is shorter and quieter, and starts from wherever the entrance is.\n  return <AnimatePresence>\n    {open && <DialogPrimitive.Portal key=\"dialog\" forceMount>\n      <DialogPrimitive.Overlay asChild forceMount><motion.div className={styles.overlay} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: reduced ? fade : leave }} transition={reduced ? fade : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}/></DialogPrimitive.Overlay>\n      <DialogPrimitive.Content {...props} onPointerDownOutside={pressOutside} asChild forceMount>\n        <motion.div className={classes} initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, scale: .96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduced ? { opacity: 0, transition: fade } : { opacity: 0, y: 4, scale: .98, transition: leave }} transition={reduced ? fade : { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }}>{inner}</motion.div>\n      </DialogPrimitive.Content>\n    </DialogPrimitive.Portal>}\n  </AnimatePresence>;\n}\n"
    },
    {
      "path": "registry/components/dialog/dialog.module.css",
      "type": "registry:component",
      "target": "@components/xzero/dialog/dialog.module.css",
      "content": ".overlay { position: fixed; inset: 0; z-index: 50; background: oklch(10% 0 0 / .46); -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px); }\n/* Centered with auto margins (like a native modal dialog) so transform stays free for the entrance spring. */\n.content { position: fixed; z-index: 51; inset: 0; width: min(calc(100vw - var(--space-8)), 440px); height: fit-content; max-height: calc(100vh - var(--space-8)); max-height: calc(100dvh - var(--space-8)); margin: auto; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); }\n/* Motion animates both layers. Keyframes only run when DialogContent sits under a bare Radix root. */\n.keyframes.overlay[data-state=\"open\"] { animation: overlay-in var(--duration-standard) var(--ease-enter) both; }\n.keyframes.overlay[data-state=\"closed\"] { animation: overlay-out var(--duration-fast) var(--ease-standard) both; }\n.keyframes.content[data-state=\"open\"] { animation: content-fade-in var(--duration-fast) var(--ease-enter) both, content-rise-in var(--duration-spring) var(--ease-spring) both; }\n.keyframes.content[data-state=\"closed\"] { animation: content-out var(--duration-fast) var(--ease-standard) both; }\n.content:focus { outline: none; }\n/* While the layers leave, clicks pass through, so the trigger can reopen the dialog mid-exit. Radix sets pointer-events inline on the overlay. */\n.overlay[data-state=\"closed\"], .content[data-state=\"closed\"] { pointer-events: none !important; }\n.header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-5); padding: var(--space-6); border-bottom: 1px solid var(--border); }\n.title { margin: 0; font-family: var(--font-body); font-size: var(--text-lg); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }\n.description { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n.title, .description { position: relative; overflow-wrap: anywhere; }\n.swap { display: block; }\n/* Line height sits on the text itself, so a page-level heading reset cannot shrink the title and pull the close button off its line. */\n.title .swap { line-height: var(--leading-body); }\n/* A compact icon button: quick press, spring release. The negative margin centres it on the title's first line. */\n.close { flex: 0 0 auto; margin-block: calc((var(--text-lg) * var(--leading-body) - var(--space-8)) / 2); display: grid; place-items: center; width: var(--space-8); height: var(--space-8); border: 1px solid var(--border); border-radius: var(--radius-control); background: color-mix(in oklab, var(--surface-muted) 50%, transparent); color: var(--text-secondary); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n@media (hover: hover) and (pointer: fine) { .close:hover { background: var(--surface-muted); color: var(--foreground); } }\n.close:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.body { padding: var(--space-6); font-size: var(--text-sm); line-height: var(--leading-body); }\n@keyframes overlay-in { from { opacity: 0; } }\n@keyframes overlay-out { to { opacity: 0; } }\n@keyframes content-fade-in { from { opacity: 0; } }\n@keyframes content-rise-in { from { transform: translateY(8px) scale(.96); } }\n@keyframes content-out { to { opacity: 0; transform: translateY(4px) scale(.98); } }\n@keyframes content-fade-out { to { opacity: 0; } }\n@media (prefers-reduced-motion: reduce) {\n  .keyframes.overlay[data-state=\"open\"], .keyframes.content[data-state=\"open\"] { animation: overlay-in var(--duration-instant) var(--ease-standard) both; }\n  .keyframes.overlay[data-state=\"closed\"] { animation: overlay-out var(--duration-instant) var(--ease-standard) both; }\n  .keyframes.content[data-state=\"closed\"] { animation: content-fade-out var(--duration-instant) var(--ease-standard) both; }\n  .close, .close:active { transition: none; }\n  .close:active { transform: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/dialog",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "modal",
      "overlay"
    ],
    "docs": "https://ui.xzero.co.uk/components/dialog",
    "markdown": "https://ui.xzero.co.uk/components/dialog/markdown"
  }
}