{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dialog","type":"registry:ui","title":"Dialog","description":"Accessible base modal with a featured icon, four widths and stacked phone actions; it springs in over a blurred scrim and turns into a drag-to-dismiss bottom sheet below 640px.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/dialog.tsx","type":"registry:ui","target":"components/ui/dialog.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useAnimate, useDragControls, useReducedMotion, type PanInfo } from \"motion/react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DialogSize = \"sm\" | \"md\" | \"lg\" | \"xl\";\nexport type DialogTone = \"neutral\" | \"volt\" | \"success\" | \"warning\" | \"danger\" | \"ember\";\n\nexport interface DialogApi {\n  close: () => void;\n}\n\ntype Slot = React.ReactNode | ((api: DialogApi) => React.ReactNode);\n\nexport interface DialogProps {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Element that opens the dialog; receives onClick, aria-haspopup and aria-expanded. */\n  trigger?: React.ReactElement<React.HTMLAttributes<HTMLElement>>;\n  /** Use \"alertdialog\" for confirmations that interrupt the user. */\n  role?: \"dialog\" | \"alertdialog\";\n  /** Max width: sm 400 · md 480 · lg 640 · xl 800. */\n  size?: DialogSize;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  /** Featured icon shown in a tinted tile in the header. */\n  icon?: React.ReactNode;\n  tone?: DialogTone;\n  /** Header arrangement: icon above text, icon beside text (from 640px), or centred. */\n  layout?: \"stacked\" | \"inline\" | \"centered\";\n  /** Body. A function receives `{ close }`. */\n  children?: Slot;\n  /** Actions, primary last. Stacked full-width on phones with the primary on top. */\n  footer?: Slot;\n  /** Left side of the footer, e.g. a \"Don't show again\" checkbox. */\n  footerAside?: Slot;\n  closeOnEscape?: boolean;\n  /** Close on scrim click (and drag-to-dismiss on the mobile sheet). When false the panel nudges instead. */\n  closeOnScrim?: boolean;\n  showClose?: boolean;\n  /** Element to focus on open. Defaults to [data-autofocus], then the first control in the body, then the footer. */\n  initialFocus?: React.RefObject<HTMLElement | null>;\n  /** Below 640px, present as a bottom sheet with drag-to-dismiss. */\n  sheetOnMobile?: boolean;\n  className?: string;\n  bodyClassName?: string;\n}\n\nconst FOCUSABLE =\n  'a[href],button:not([disabled]),input:not([disabled]):not([type=\"hidden\"]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\nconst widths: Record<DialogSize, string> = {\n  sm: \"sm:max-w-[400px]\",\n  md: \"sm:max-w-[480px]\",\n  lg: \"sm:max-w-[640px]\",\n  xl: \"sm:max-w-[800px]\",\n};\nconst tones: Record<DialogTone, string> = {\n  neutral: \"bg-surface-sunken text-ink-muted\",\n  volt: \"bg-volt-soft text-volt-ink\",\n  success: \"bg-success-soft text-success\",\n  warning: \"bg-warning-soft text-warning\",\n  danger: \"bg-danger-soft text-danger\",\n  ember: \"bg-ember-soft text-ember-ink\",\n};\n\nconst noopSubscribe = () => () => {};\nconst MOBILE = \"(max-width: 639.98px)\";\nfunction subscribeMobile(cb: () => void) {\n  const mq = window.matchMedia(MOBILE);\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\n\nconst DialogContext = React.createContext<DialogApi | null>(null);\n\n/** Close the surrounding Dialog from any descendant. */\nexport function useDialog(): DialogApi {\n  const ctx = React.useContext(DialogContext);\n  if (!ctx) throw new Error(\"useDialog must be used inside <Dialog>\");\n  return ctx;\n}\n\nconst render = (slot: Slot, api: DialogApi) => (typeof slot === \"function\" ? slot(api) : slot);\n\n/**\n * Accessible modal: focus trap, scroll lock, focus restore, Escape and scrim dismissal, a tinted featured icon and a\n * width scale. It springs in over a blurred scrim and turns into a draggable bottom sheet below 640px.\n */\nexport function Dialog({ open: openProp, defaultOpen = false, onOpenChange, trigger, ...panel }: DialogProps) {\n  const [inner, setInner] = React.useState(defaultOpen);\n  const open = openProp ?? inner;\n  const mounted = React.useSyncExternalStore(\n    noopSubscribe,\n    () => true,\n    () => false,\n  );\n\n  const setOpen = React.useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setInner(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  return (\n    <>\n      {trigger &&\n        React.cloneElement(trigger, {\n          \"aria-haspopup\": \"dialog\",\n          \"aria-expanded\": open,\n          onClick: (e: React.MouseEvent<HTMLElement>) => {\n            trigger.props.onClick?.(e);\n            if (!e.defaultPrevented) setOpen(true);\n          },\n        })}\n      {mounted &&\n        createPortal(\n          <AnimatePresence>{open && <DialogPanel key=\"dialog\" {...panel} onClose={() => setOpen(false)} />}</AnimatePresence>,\n          document.body,\n        )}\n    </>\n  );\n}\n\ninterface DialogPanelProps extends Omit<DialogProps, \"open\" | \"defaultOpen\" | \"onOpenChange\" | \"trigger\"> {\n  onClose: () => void;\n}\n\nfunction DialogPanel({\n  role = \"dialog\",\n  size = \"md\",\n  title,\n  description,\n  icon,\n  tone = \"neutral\",\n  layout = \"stacked\",\n  children,\n  footer,\n  footerAside,\n  closeOnEscape = true,\n  closeOnScrim = true,\n  showClose = true,\n  initialFocus,\n  sheetOnMobile = true,\n  className,\n  bodyClassName,\n  onClose,\n}: DialogPanelProps) {\n  const titleId = React.useId();\n  const descId = React.useId();\n  const reduce = useReducedMotion();\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const drag = useDragControls();\n  const mobile = React.useSyncExternalStore(\n    subscribeMobile,\n    () => window.matchMedia(MOBILE).matches,\n    () => false,\n  );\n  const sheet = sheetOnMobile && mobile;\n\n  const closeRef = React.useRef(onClose);\n  const optsRef = React.useRef({ closeOnEscape, initialFocus });\n  React.useEffect(() => {\n    closeRef.current = onClose;\n    optsRef.current = { closeOnEscape, initialFocus };\n  });\n  const api = React.useMemo<DialogApi>(() => ({ close: onClose }), [onClose]);\n\n  const nudge = React.useCallback(() => {\n    if (reduce || !scope.current) return;\n    animate(scope.current, { scale: [1, 1.018, 1] }, { duration: 0.32, ease: \"easeOut\" });\n  }, [animate, reduce, scope]);\n\n  // Scroll lock, initial focus, Escape, focus containment and focus restore.\n  React.useEffect(() => {\n    const prev = document.activeElement as HTMLElement | null;\n    const prevOverflow = document.body.style.overflow;\n    const prevPadding = document.body.style.paddingRight;\n    const gap = window.innerWidth - document.documentElement.clientWidth;\n    document.body.style.overflow = \"hidden\";\n    if (gap > 0) document.body.style.paddingRight = `${gap}px`;\n\n    const raf = requestAnimationFrame(() => {\n      const p = panelRef.current;\n      if (!p) return;\n      const target =\n        optsRef.current.initialFocus?.current ??\n        p.querySelector<HTMLElement>(\"[data-autofocus]\") ??\n        p.querySelector<HTMLElement>(`[data-dialog-body] :is(${FOCUSABLE})`) ??\n        p.querySelector<HTMLElement>(`[data-dialog-actions] :is(${FOCUSABLE})`) ??\n        p.querySelector<HTMLElement>(`[data-dialog-footer] :is(${FOCUSABLE})`) ??\n        p;\n      target.focus({ preventScroll: true });\n    });\n\n    function onKey(e: KeyboardEvent) {\n      if (e.key !== \"Escape\" || e.defaultPrevented) return;\n      e.preventDefault();\n      if (optsRef.current.closeOnEscape) closeRef.current();\n    }\n    // Pull focus back if it escapes (menus and nested dialogs portalled elsewhere are allowed).\n    function onFocusIn(e: FocusEvent) {\n      const t = e.target as HTMLElement | null;\n      const p = panelRef.current;\n      if (!p || !t || p.contains(t) || t.closest('[role=\"menu\"],[role=\"listbox\"],[role=\"dialog\"],[role=\"alertdialog\"]')) return;\n      p.focus({ preventScroll: true });\n    }\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"focusin\", onFocusIn);\n    return () => {\n      cancelAnimationFrame(raf);\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"focusin\", onFocusIn);\n      document.body.style.overflow = prevOverflow;\n      document.body.style.paddingRight = prevPadding;\n      prev?.focus?.({ preventScroll: true });\n    };\n  }, []);\n\n  function trapTab(e: React.KeyboardEvent) {\n    if (e.key !== \"Tab\" || !panelRef.current) return;\n    const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((n) => n.offsetParent !== null);\n    if (!nodes.length) {\n      e.preventDefault();\n      return;\n    }\n    const first = nodes[0];\n    const last = nodes[nodes.length - 1];\n    if (e.shiftKey && (document.activeElement === first || document.activeElement === panelRef.current)) {\n      e.preventDefault();\n      last.focus();\n    } else if (!e.shiftKey && document.activeElement === last) {\n      e.preventDefault();\n      first.focus();\n    }\n  }\n\n  function onDragEnd(_: unknown, info: PanInfo) {\n    if (info.offset.y > 110 || info.velocity.y > 600) closeRef.current();\n  }\n\n  const spring = { type: \"spring\" as const, stiffness: 340, damping: 30 };\n  const enter = reduce\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.16 } }\n    : sheet\n      ? { initial: { y: \"100%\" }, animate: { y: 0 }, exit: { y: \"100%\" }, transition: { type: \"spring\" as const, stiffness: 380, damping: 38 } }\n      : {\n          initial: { opacity: 0, scale: 0.94, y: 10, filter: \"blur(6px)\" },\n          animate: { opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" },\n          exit: { opacity: 0, scale: 0.97, y: 4, filter: \"blur(4px)\", transition: { duration: 0.16 } },\n          transition: { ...spring, opacity: { duration: 0.2 }, filter: { duration: 0.2 } },\n        };\n\n  const iconTile = icon ? (\n    <motion.span\n      aria-hidden\n      initial={reduce ? false : { scale: 0.5, opacity: 0, rotate: tone === \"warning\" ? 0 : -14 }}\n      animate={\n        reduce\n          ? undefined\n          : tone === \"warning\"\n            ? { scale: 1, opacity: 1, rotate: [0, -12, 10, -6, 4, 0] }\n            : { scale: 1, opacity: 1, rotate: 0 }\n      }\n      transition={{ type: \"spring\", stiffness: 420, damping: 18, delay: 0.08, rotate: tone === \"warning\" ? { duration: 0.6, delay: 0.2 } : undefined }}\n      className={cn(\"relative grid size-11 shrink-0 place-items-center rounded-md [&>svg]:size-5\", tones[tone])}\n    >\n      <span className=\"absolute -inset-1.5 rounded-[18px] border border-current opacity-15\" />\n      {icon}\n    </motion.span>\n  ) : null;\n\n  const hasBody = children !== undefined && children !== null && children !== false;\n  const hasFooter = !!footer || !!footerAside;\n\n  return (\n    <DialogContext.Provider value={api}>\n      <div className={cn(\"fixed inset-0 z-[90] flex justify-center\", sheet ? \"items-end\" : \"items-center p-4 sm:p-6\")}>\n        <motion.div\n          aria-hidden\n          className=\"absolute inset-0 bg-scrim backdrop-blur-[6px]\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0, transition: { duration: 0.18 } }}\n          transition={{ duration: 0.22 }}\n          onClick={() => (closeOnScrim ? closeRef.current() : nudge())}\n        />\n        <motion.div\n          ref={panelRef}\n          role={role}\n          aria-modal=\"true\"\n          aria-labelledby={titleId}\n          aria-describedby={description ? descId : undefined}\n          tabIndex={-1}\n          onKeyDown={trapTab}\n          {...enter}\n          drag={sheet && closeOnScrim && !reduce ? \"y\" : false}\n          dragListener={false}\n          dragControls={drag}\n          dragConstraints={{ top: 0, bottom: 0 }}\n          dragElastic={{ top: 0.04, bottom: 0.9 }}\n          dragMomentum={false}\n          onDragEnd={onDragEnd}\n          className={cn(\n            \"relative isolate flex w-full flex-col border border-border bg-surface-raised text-ink shadow-lg outline-none\",\n            sheet ? \"max-h-[92dvh] rounded-t-xl border-b-0\" : cn(\"max-h-[calc(100dvh-32px)] rounded-xl sm:max-h-[calc(100dvh-48px)]\", widths[size]),\n            className,\n          )}\n        >\n          <div ref={scope} className=\"flex min-h-0 flex-1 flex-col\">\n            <div\n              onPointerDown={sheet ? (e) => drag.start(e) : undefined}\n              className={cn(\"relative shrink-0\", sheet && \"touch-none\")}\n            >\n              {sheet && (\n                <div aria-hidden className=\"flex justify-center pb-1 pt-2.5\">\n                  <span className=\"h-1 w-10 rounded-full bg-border-strong\" />\n                </div>\n              )}\n              <header\n                className={cn(\n                  \"flex gap-4 px-5 sm:px-6\",\n                  sheet ? \"pt-2\" : \"pt-6\",\n                  layout === \"inline\" ? \"flex-col sm:flex-row sm:items-start\" : \"flex-col\",\n                  layout === \"centered\" && \"items-center text-center\",\n                )}\n              >\n                {iconTile}\n                <div className={cn(\"min-w-0 flex-1\", showClose && layout !== \"centered\" && \"pr-8\", layout === \"centered\" && \"px-6\")}>\n                  <h2 id={titleId} className=\"font-display text-[17px] font-semibold leading-6 tracking-[-0.2px]\">\n                    {title}\n                  </h2>\n                  {description && (\n                    <div id={descId} className=\"mt-1 text-[13.5px] leading-5 text-ink-muted\">\n                      {description}\n                    </div>\n                  )}\n                </div>\n              </header>\n              {showClose && (\n                <button\n                  type=\"button\"\n                  aria-label=\"Close\"\n                  onClick={() => closeRef.current()}\n                  onPointerDown={(e) => e.stopPropagation()}\n                  className={cn(\n                    \"absolute right-3 grid size-8 place-items-center rounded-[10px] text-ink-subtle outline-none transition-colors hover:bg-surface-hover hover:text-ink focus-visible:shadow-[var(--focus-ring)] sm:right-4\",\n                    sheet ? \"top-5\" : \"top-4\",\n                  )}\n                >\n                  <X className=\"size-4\" aria-hidden />\n                </button>\n              )}\n            </div>\n            {hasBody && (\n              <div data-dialog-body=\"\" className={cn(\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-5 sm:px-6\", !hasFooter && \"pb-6\", bodyClassName)}>\n                {render(children, api)}\n              </div>\n            )}\n            {hasFooter && (\n              <div\n                data-dialog-footer=\"\"\n                className={cn(\n                  \"flex shrink-0 flex-col gap-3 px-5 pt-6 sm:flex-row sm:items-center sm:px-6\",\n                  sheet ? \"pb-[max(20px,env(safe-area-inset-bottom))]\" : \"pb-6\",\n                  !hasBody && \"pt-6\",\n                )}\n              >\n                {footerAside && <div className=\"min-w-0 text-[13px] text-ink-muted sm:mr-auto\">{render(footerAside, api)}</div>}\n                {footer && (\n                  <div data-dialog-actions=\"\" className=\"flex flex-col-reverse gap-2 sm:ml-auto sm:flex-row sm:items-center [&>*]:w-full sm:[&>*]:w-auto\">{render(footer, api)}</div>\n                )}\n              </div>\n            )}\n          </div>\n        </motion.div>\n      </div>\n    </DialogContext.Provider>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","dialog","modal","alert dialog","confirm","sheet","overlay","focus trap"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/dialog"}}