{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-dialog",
  "type": "registry:ui",
  "title": "Animated Dialog",
  "description": "A native <dialog> modal that scales in from the button that opened it, with a blurred backdrop, alertdialog mode and nesting.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/animated-dialog.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, type AnimationPlaybackControls } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface AnimatedDialogProps {\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. */\n  defaultOpen?: boolean;\n  /** Called whenever the dialog asks to open or close (trigger, close button, Escape, backdrop). */\n  onOpenChange?: (open: boolean) => void;\n  /** Element that opens the dialog (usually a button). It gets onClick and aria-haspopup / aria-expanded. */\n  trigger?: React.ReactElement<React.HTMLAttributes<HTMLElement>>;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  /** Dialog body. */\n  children?: React.ReactNode;\n  /** Actions row; `AnimatedDialogClose` buttons inside it close the dialog. */\n  footer?: React.ReactNode;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** \"alertdialog\" for confirmations: focus starts on the least destructive action. */\n  role?: \"dialog\" | \"alertdialog\";\n  closeOnEscape?: boolean;\n  closeOnBackdrop?: boolean;\n  /** Show the × button in the header. */\n  showClose?: boolean;\n  /** Backdrop blur in px. */\n  blur?: number;\n  /** Spring for the scale-in from the trigger. */\n  spring?: Spring;\n  /** Element to focus when the dialog opens (otherwise `[data-autofocus]`, then a sensible default). */\n  initialFocus?: React.RefObject<HTMLElement | null>;\n  /** Focus ring colour. */\n  accent?: string;\n  /** Extra classes for the panel. */\n  className?: string;\n}\n\nconst SIZES = { sm: 400, md: 520, lg: 680 } as const;\nconst FOCUSABLE = 'a[href], button:not(:disabled), input:not(:disabled):not([type=\"hidden\"]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex=\"-1\"])';\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* Scroll lock shared by every open dialog (nested dialogs lock once). */\nlet lockCount = 0;\nlet saved = { overflow: \"\", paddingRight: \"\" };\nfunction lockScroll() {\n  if (lockCount++ > 0) return;\n  const root = document.documentElement;\n  saved = { overflow: root.style.overflow, paddingRight: root.style.paddingRight };\n  const scrollbar = window.innerWidth - root.clientWidth;\n  // Keep the layout from jumping when the scrollbar disappears (unless a gutter is already reserved).\n  if (scrollbar > 0 && !getComputedStyle(root).scrollbarGutter?.includes(\"stable\")) {\n    root.style.paddingRight = `${parseFloat(getComputedStyle(root).paddingRight) + scrollbar}px`;\n  }\n  root.style.overflow = \"hidden\";\n}\nfunction unlockScroll() {\n  if (lockCount === 0 || --lockCount > 0) return;\n  const root = document.documentElement;\n  root.style.overflow = saved.overflow;\n  root.style.paddingRight = saved.paddingRight;\n}\n\nconst DialogContext = React.createContext<{ close: () => void } | null>(null);\n\n/** A button that closes the nearest AnimatedDialog. Put your Cancel / Done actions in it. */\nexport function AnimatedDialogClose({ onClick, type = \"button\", ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {\n  const ctx = React.useContext(DialogContext);\n  return (\n    <button\n      type={type}\n      data-dialog-close=\"\"\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) ctx?.close();\n      }}\n      {...props}\n    />\n  );\n}\n\n/**\n * A modal dialog on the native <dialog> element (top layer, inert page, focus trapping by the\n * browser). It scales in from the trigger that opened it over a blurred, fading backdrop and\n * shrinks back towards it on close. Escape and backdrop clicks are configurable, `alertdialog`\n * focuses the least destructive action, focus returns to the trigger, page scroll is locked and\n * dialogs can be nested. With reduced motion it only fades.\n */\nexport function AnimatedDialog({\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  trigger,\n  title,\n  description,\n  children,\n  footer,\n  size = \"md\",\n  role = \"dialog\",\n  closeOnEscape = true,\n  closeOnBackdrop = true,\n  showClose = true,\n  blur = 6,\n  spring = { stiffness: 380, damping: 30, mass: 0.8 },\n  initialFocus,\n  accent = \"#FF7448\",\n  className = \"\",\n}: AnimatedDialogProps) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const [internal, setInternal] = React.useState(defaultOpen);\n  const isOpen = openProp ?? internal;\n  const dialogRef = React.useRef<HTMLDialogElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const backdropRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLElement | null>(null);\n  const returnFocus = React.useRef<HTMLElement | null>(null);\n  const locked = React.useRef(false);\n  const closing = React.useRef(false);\n  const animations = React.useRef<AnimationPlaybackControls[]>([]);\n  const pressStartedOnBackdrop = React.useRef(false);\n\n  const setOpen = (next: boolean) => {\n    if (openProp === undefined) setInternal(next);\n    onOpenChange?.(next);\n  };\n\n  React.useLayoutEffect(() => {\n    const dialog = dialogRef.current;\n    const panel = panelRef.current;\n    const backdrop = backdropRef.current;\n    if (!dialog || !panel || !backdrop) return;\n    const stop = () => {\n      animations.current.forEach((a) => a.stop());\n      animations.current = [];\n    };\n\n    if (isOpen) {\n      if (dialog.open && !closing.current) return;\n      closing.current = false;\n      stop();\n      if (!dialog.open) {\n        returnFocus.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;\n        dialog.showModal();\n        if (!locked.current) {\n          lockScroll();\n          locked.current = true;\n        }\n      }\n      // Scale in from the trigger: put the transform origin at its centre, in panel coordinates.\n      const anchor = triggerRef.current ?? returnFocus.current;\n      panel.style.transform = \"none\";\n      const p = panel.getBoundingClientRect();\n      if (anchor && anchor.isConnected) {\n        const a = anchor.getBoundingClientRect();\n        panel.style.transformOrigin = `${Math.round(a.left + a.width / 2 - p.left)}px ${Math.round(a.top + a.height / 2 - p.top)}px`;\n      } else {\n        panel.style.transformOrigin = \"50% 50%\";\n      }\n      animations.current = reduceMotion\n        ? [animate(panel, { opacity: [0, 1] }, { duration: 0.16 }), animate(backdrop, { opacity: [0, 1] }, { duration: 0.16 })]\n        : [\n            animate(panel, { opacity: [0, 1], scale: [0.35, 1] }, { type: \"spring\", ...spring, opacity: { duration: 0.18, ease: \"easeOut\" } }),\n            animate(backdrop, { opacity: [0, 1], backdropFilter: [\"blur(0px)\", `blur(${blur}px)`] }, { duration: 0.28, ease: \"easeOut\" }),\n          ];\n      if (reduceMotion) backdrop.style.backdropFilter = `blur(${blur}px)`;\n\n      // Initial focus: explicit → [data-autofocus] → (alertdialog) the first close action → first control → panel.\n      // Only this dialog's own elements count, not those of a nested dialog inside its body.\n      const find = (selector: string) => Array.from(panel.querySelectorAll<HTMLElement>(selector)).find((el) => el.closest(\"dialog\") === dialog);\n      const explicit = initialFocus?.current ?? find(\"[data-autofocus]\");\n      const fallback =\n        role === \"alertdialog\"\n          ? (find(\"[data-dialog-footer] [data-dialog-close]\") ?? find(\"[data-dialog-footer] button\"))\n          : find(`[data-dialog-content] :is(${FOCUSABLE}), [data-dialog-footer] :is(${FOCUSABLE})`);\n      (explicit ?? fallback ?? panel).focus({ preventScroll: true });\n      return;\n    }\n\n    if (!dialog.open || closing.current) return;\n    closing.current = true;\n    stop();\n    const out = reduceMotion\n      ? [animate(panel, { opacity: 0 }, { duration: 0.12 }), animate(backdrop, { opacity: 0 }, { duration: 0.12 })]\n      : [\n          animate(panel, { opacity: 0, scale: 0.6 }, { duration: 0.18, ease: [0.4, 0, 1, 1] }),\n          animate(backdrop, { opacity: 0, backdropFilter: \"blur(0px)\" }, { duration: 0.2, ease: \"easeIn\" }),\n        ];\n    animations.current = out;\n    void Promise.all(out.map((a) => a.finished)).then(() => {\n      if (closing.current && dialog.open) dialog.close();\n    });\n  }, [isOpen, reduceMotion, spring, blur, role, initialFocus]);\n\n  // Unmounting while open: stop animations, close the dialog and release the scroll lock.\n  React.useEffect(() => {\n    const dialog = dialogRef.current;\n    return () => {\n      animations.current.forEach((a) => a.stop());\n      if (dialog?.open) dialog.close();\n      if (locked.current) {\n        unlockScroll();\n        locked.current = false;\n      }\n    };\n  }, []);\n\n  const requestClose = () => setOpen(false);\n\n  const triggerElement = trigger\n    ? React.cloneElement(trigger, {\n        \"aria-haspopup\": \"dialog\",\n        \"aria-expanded\": isOpen,\n        \"aria-controls\": isOpen ? `${uid}-dialog` : undefined,\n        onClick: (event: React.MouseEvent<HTMLElement>) => {\n          trigger.props.onClick?.(event);\n          if (event.defaultPrevented) return;\n          triggerRef.current = event.currentTarget;\n          setOpen(true);\n        },\n      })\n    : null;\n\n  return (\n    <>\n      {triggerElement}\n      <dialog\n        ref={dialogRef}\n        id={`${uid}-dialog`}\n        role={role === \"alertdialog\" ? \"alertdialog\" : undefined}\n        aria-labelledby={`${uid}-title`}\n        aria-describedby={description ? `${uid}-desc` : undefined}\n        onCancel={(event) => {\n          // Nested dialogs: React bubbles this through the component tree; only handle our own.\n          if (event.target !== event.currentTarget) return;\n          event.preventDefault();\n          if (closeOnEscape) requestClose();\n        }}\n        onClose={(event) => {\n          if (event.target !== event.currentTarget) return;\n          closing.current = false;\n          animations.current.forEach((a) => a.stop());\n          if (locked.current) {\n            unlockScroll();\n            locked.current = false;\n          }\n          // The browser can force-close (e.g. repeated Escape); keep state in sync.\n          if (isOpen) setOpen(false);\n          const target = returnFocus.current;\n          returnFocus.current = null;\n          triggerRef.current = null;\n          if (target?.isConnected && (document.activeElement === document.body || document.activeElement === null)) target.focus({ preventScroll: true });\n        }}\n        className=\"fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 text-left backdrop:bg-transparent\"\n        style={{ [\"--ad-accent\" as string]: accent }}\n      >\n        <div ref={backdropRef} aria-hidden=\"true\" className=\"absolute inset-0 bg-zinc-950/35 opacity-0 dark:bg-black/55\" />\n        <div\n          className=\"relative flex h-full w-full items-center justify-center overflow-y-auto p-4\"\n          onPointerDown={(event) => {\n            pressStartedOnBackdrop.current = event.target === event.currentTarget;\n          }}\n          onClick={(event) => {\n            // Only a press that starts and ends outside the panel counts (drags out of inputs don't close).\n            if (event.target === event.currentTarget && pressStartedOnBackdrop.current && closeOnBackdrop) requestClose();\n            pressStartedOnBackdrop.current = false;\n          }}\n        >\n          <div\n            ref={panelRef}\n            tabIndex={-1}\n            className={`relative flex max-h-[calc(100dvh-2rem)] w-full flex-col overflow-hidden rounded-2xl border border-zinc-900/10 bg-white text-zinc-900 opacity-0 shadow-[0_32px_80px_-24px_rgb(0_0_0/0.45),0_2px_8px_-2px_rgb(0_0_0/0.08)] outline-hidden dark:border-white/10 dark:bg-[#16191c] dark:text-zinc-100 dark:shadow-[0_32px_80px_-20px_rgb(0_0_0/0.9)] ${className}`}\n            style={{ maxWidth: SIZES[size] ?? SIZES.md }}\n          >\n            <DialogContext.Provider value={{ close: requestClose }}>\n              <div className=\"flex items-start gap-4 px-6 pb-1 pt-5\">\n                <div className=\"min-w-0 flex-1\">\n                  <h2 id={`${uid}-title`} className=\"text-[17px] font-semibold leading-snug tracking-[-0.015em]\">\n                    {title}\n                  </h2>\n                  {description ? (\n                    <p id={`${uid}-desc`} className=\"mt-1 text-[13px] leading-relaxed text-zinc-500 dark:text-zinc-400\">\n                      {description}\n                    </p>\n                  ) : null}\n                </div>\n                {showClose ? (\n                  <button\n                    type=\"button\"\n                    aria-label=\"Close\"\n                    onClick={requestClose}\n                    className=\"-mr-2 -mt-1 grid size-8 shrink-0 place-items-center rounded-lg text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--ad-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.07] dark:hover:text-white\"\n                  >\n                    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-4\">\n                      <path d=\"M4 4l8 8M12 4l-8 8\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n                    </svg>\n                  </button>\n                ) : null}\n              </div>\n              {children ? (\n                <div data-dialog-content=\"\" className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-5 pt-3 text-[14px] leading-relaxed text-zinc-600 dark:text-zinc-300\">\n                  {children}\n                </div>\n              ) : (\n                <div className=\"h-4\" />\n              )}\n              {footer ? (\n                <div data-dialog-footer=\"\" className=\"flex flex-wrap items-center justify-end gap-2 border-t border-zinc-900/[0.07] bg-zinc-50/70 px-6 py-3.5 dark:border-white/[0.07] dark:bg-white/[0.02]\">\n                  {footer}\n                </div>\n              ) : null}\n            </DialogContext.Provider>\n          </div>\n        </div>\n      </dialog>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/animated-dialog"
}
