{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-deck",
  "type": "registry:component",
  "title": "Toast Deck",
  "description": "Notifications that settle into a tidy deck, fan open when you hover, swipe away to the side and turn from a spinner into a check when the work behind them finishes.",
  "categories": [
    "component",
    "Status"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/toast-deck.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CircleAlertIcon, CircleCheckIcon, InfoIcon, LoaderCircleIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, type PanInfo, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ToastTone = \"default\" | \"success\" | \"error\" | \"info\" | \"loading\";\n\nexport interface ToastData {\n  id: string;\n  title: ReactNode;\n  description?: ReactNode;\n  tone?: ToastTone;\n  action?: { label: string; onClick: () => void };\n  duration?: number;\n}\n\ntype ToastInput = Omit<ToastData, \"id\"> & { id?: string };\n\ntype PromiseMessages<T> = {\n  loading: ReactNode;\n  success: ReactNode | ((value: T) => ReactNode);\n  error: ReactNode | ((error: unknown) => ReactNode);\n  description?: ReactNode;\n};\n\nlet counter = 0;\n\nexport function useToastDeck({ limit = 4 }: { limit?: number } = {}) {\n  const [toasts, setToasts] = useState<ToastData[]>([]);\n\n  const dismiss = useCallback((id: string) => setToasts((list) => list.filter((t) => t.id !== id)), []);\n\n  const show = useCallback((input: ToastInput) => {\n    const id = input.id ?? `t${++counter}`;\n    setToasts((list) => {\n      const exists = list.some((t) => t.id === id);\n      return exists ? list.map((t) => (t.id === id ? { ...t, ...input, id } : t)) : [...list, { ...input, id }].slice(-limit);\n    });\n    return id;\n  }, [limit]);\n\n  const promise = useCallback(\n    async <T,>(task: Promise<T>, messages: PromiseMessages<T>) => {\n      const id = show({ title: messages.loading, description: messages.description, tone: \"loading\", duration: Infinity });\n      try {\n        const value = await task;\n        const title = typeof messages.success === \"function\" ? messages.success(value) : messages.success;\n        show({ id, title, tone: \"success\", description: undefined, duration: 4000 });\n        return value;\n      } catch (error) {\n        const title = typeof messages.error === \"function\" ? messages.error(error) : messages.error;\n        show({ id, title, tone: \"error\", description: undefined, duration: 5000 });\n        throw error;\n      }\n    },\n    [show],\n  );\n\n  return useMemo(() => ({ toasts, show, dismiss, promise }), [toasts, show, dismiss, promise]);\n}\n\nexport type ToastDeckStore = ReturnType<typeof useToastDeck>;\n\nconst icons: Record<ToastTone, ReactNode> = {\n  default: null,\n  success: <CircleCheckIcon className=\"text-success\" />,\n  error: <CircleAlertIcon className=\"text-destructive\" />,\n  info: <InfoIcon className=\"text-info\" />,\n  loading: <LoaderCircleIcon className=\"animate-spin text-muted-foreground\" />,\n};\n\nconst PEEK = 10;\nconst GAP = 8;\nconst VISIBLE = 3;\n\nexport interface ToastDeckProps {\n  deck: ToastDeckStore;\n  className?: string;\n}\n\nexport function ToastDeck({ deck, className }: ToastDeckProps) {\n  const { toasts, dismiss } = deck;\n  const [expanded, setExpanded] = useState(false);\n  const [heights, setHeights] = useState<Record<string, number>>({});\n  const reduce = useReducedMotion();\n\n  const newestFirst = [...toasts].reverse();\n  const frontHeight = heights[newestFirst[0]?.id] ?? 64;\n  const offsets: number[] = [];\n  let running = 0;\n  for (const t of newestFirst) {\n    offsets.push(running);\n    running += (heights[t.id] ?? 64) + GAP;\n  }\n  const stackHeight = expanded\n    ? Math.max(running - GAP, 0)\n    : frontHeight + PEEK * Math.min(Math.max(newestFirst.length - 1, 0), VISIBLE - 1);\n\n  useEffect(() => {\n    if (toasts.length === 0) setExpanded(false);\n  }, [toasts.length]);\n\n  const setHeight = useCallback((id: string, h: number) => {\n    setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));\n  }, []);\n\n  return (\n    <section\n      aria-label=\"Notifications\"\n      className={cn(\"pointer-events-none absolute inset-x-3 bottom-3 z-10\", className)}\n    >\n      <motion.ol\n        animate={{ height: toasts.length ? stackHeight : 0 }}\n        className=\"pointer-events-auto relative\"\n        initial={false}\n        onBlur={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node)) setExpanded(false);\n        }}\n        onFocus={() => setExpanded(true)}\n        onMouseEnter={() => setExpanded(true)}\n        onMouseLeave={() => setExpanded(false)}\n        transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 400, damping: 36 }}\n      >\n        <AnimatePresence initial={false}>\n          {newestFirst.map((toast, index) => (\n            <ToastItem\n              expanded={expanded}\n              frontHeight={frontHeight}\n              index={index}\n              key={toast.id}\n              offset={offsets[index]}\n              onDismiss={() => dismiss(toast.id)}\n              onHeight={setHeight}\n              paused={expanded}\n              toast={toast}\n            />\n          ))}\n        </AnimatePresence>\n      </motion.ol>\n    </section>\n  );\n}\n\ninterface ToastItemProps {\n  toast: ToastData;\n  index: number;\n  offset: number;\n  expanded: boolean;\n  frontHeight: number;\n  paused: boolean;\n  onDismiss: () => void;\n  onHeight: (id: string, h: number) => void;\n}\n\nfunction ToastItem({ toast, index, offset, expanded, frontHeight, paused, onDismiss, onHeight }: ToastItemProps) {\n  const reduce = useReducedMotion();\n  const contentRef = useRef<HTMLDivElement>(null);\n  const [natural, setNatural] = useState(64);\n  const [swipe, setSwipe] = useState(0);\n  const tone = toast.tone ?? \"default\";\n  const duration = toast.duration ?? 4000;\n\n  useLayoutEffect(() => {\n    const el = contentRef.current;\n    if (!el) return;\n    const measure = () => {\n      setNatural(el.offsetHeight);\n      onHeight(toast.id, el.offsetHeight);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [toast.id, onHeight]);\n\n  const remaining = useRef(duration);\n  useEffect(() => {\n    remaining.current = duration;\n  }, [duration, toast.title]);\n  useEffect(() => {\n    if (paused || !Number.isFinite(remaining.current)) return;\n    const started = Date.now();\n    const id = window.setTimeout(onDismiss, remaining.current);\n    return () => {\n      clearTimeout(id);\n      remaining.current -= Date.now() - started;\n    };\n  }, [paused, onDismiss, toast.title, duration]);\n\n  const hidden = !expanded && index >= VISIBLE;\n  const y = expanded ? -offset : -index * PEEK;\n  const scale = expanded ? 1 : 1 - index * 0.05;\n  const height = expanded || index === 0 ? natural : frontHeight;\n\n  const onDragEnd = (_: unknown, info: PanInfo) => {\n    if (Math.abs(info.offset.x) > 90 || Math.abs(info.velocity.x) > 600) {\n      setSwipe(info.offset.x > 0 ? 1 : -1);\n      onDismiss();\n    }\n  };\n\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 380, damping: 32 };\n\n  return (\n    <motion.li\n      animate={{ y, scale, height, opacity: hidden ? 0 : 1 }}\n      className=\"absolute inset-x-0 bottom-0 origin-bottom touch-pan-y\"\n      drag={reduce ? false : \"x\"}\n      dragElastic={0.6}\n      dragSnapToOrigin\n      exit={\n        swipe\n          ? { x: swipe * 420, opacity: 0, transition: { duration: 0.25 } }\n          : { y: y + 16, opacity: 0, scale: scale * 0.94, transition: { duration: 0.2 } }\n      }\n      initial={reduce ? false : { y: 40, opacity: 0, scale: 0.96, height: natural }}\n      onDragEnd={onDragEnd}\n      style={{ zIndex: 100 - index }}\n      transition={spring}\n    >\n      <div\n        aria-live={tone === \"error\" ? \"assertive\" : \"polite\"}\n        className=\"group/toast relative h-full cursor-grab overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg/5 active:cursor-grabbing\"\n        role=\"status\"\n      >\n        <motion.div\n          animate={{ opacity: expanded || index === 0 ? 1 : 0 }}\n          className=\"flex items-start gap-2.5 p-3 pe-9\"\n          ref={contentRef}\n          transition={{ duration: 0.18 }}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {icons[tone] && (\n              <motion.span\n                animate={{ scale: 1, opacity: 1, rotate: 0 }}\n                className=\"mt-px flex shrink-0 [&_svg]:size-4\"\n                exit={{ scale: 0.6, opacity: 0 }}\n                initial={reduce ? false : { scale: 0.6, opacity: 0 }}\n                key={tone}\n                transition={{ type: \"spring\", stiffness: 520, damping: 22 }}\n              >\n                {icons[tone]}\n              </motion.span>\n            )}\n          </AnimatePresence>\n          <div className=\"grid min-w-0 flex-1 gap-0.5\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.p\n                animate={{ opacity: 1, y: 0 }}\n                className=\"font-medium text-sm\"\n                exit={{ opacity: 0, y: -6 }}\n                initial={reduce ? false : { opacity: 0, y: 6 }}\n                key={`${tone}`}\n              >\n                {toast.title}\n              </motion.p>\n            </AnimatePresence>\n            {toast.description && <p className=\"text-muted-foreground text-xs\">{toast.description}</p>}\n          </div>\n          {toast.action && (\n            <button\n              className=\"h-7 shrink-0 cursor-pointer self-center rounded-md bg-primary px-2.5 font-medium text-primary-foreground text-xs outline-none transition-[background-color,scale] hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n              onClick={() => {\n                toast.action?.onClick();\n                onDismiss();\n              }}\n              onPointerDownCapture={(e) => e.stopPropagation()}\n              type=\"button\"\n            >\n              {toast.action.label}\n            </button>\n          )}\n        </motion.div>\n        <button\n          aria-label=\"Dismiss\"\n          className=\"absolute top-2 right-2 flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover/toast:opacity-100 [&_svg]:size-3.5\"\n          onClick={onDismiss}\n          onPointerDownCapture={(e) => e.stopPropagation()}\n          type=\"button\"\n        >\n          <XIcon />\n        </button>\n      </div>\n    </motion.li>\n  );\n}\n"
    }
  ]
}