{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-toast","type":"registry:component","title":"Toast (Motion)","description":"Toast with motion: toasts spring in and the stack glides to make room, a changing toast pops its icon over, and a swipe sends one away.","dependencies":["lucide-react","motion"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/motion-button.json"],"files":[{"path":"registry/dashral/motion/toast.tsx","type":"registry:file","target":"components/dashral/motion/toast.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { CircleCheck, CircleX, Info, Loader2, TriangleAlert } from \"lucide-react\"\nimport { AnimatePresence, motion, type PanInfo } from \"motion/react\"\nimport { cn } from \"../lib/utils\"\n\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\nimport { Button } from \"./button\"\n\ntype ToastType = \"default\" | \"success\" | \"info\" | \"warning\" | \"error\" | \"loading\"\ntype ToastId = string | number\n\nexport type ToastOptions = {\n  /** Pass an existing toast's id to change it in place (a \"Saving…\" toast becoming \"Saved\"). */\n  id?: ToastId\n  description?: React.ReactNode\n  /** A button in the toast; clicking it also closes the toast. */\n  action?: { label: React.ReactNode; onClick: (event: React.MouseEvent<HTMLButtonElement>) => void }\n  /** How long it stays, in milliseconds; Infinity keeps it until it's closed. Loading toasts stay by default. */\n  duration?: number\n  /** In place of the type's icon. */\n  icon?: React.ReactNode\n  /** Called when it closes, by its timer, a swipe or toast.dismiss(). */\n  onDismiss?: () => void\n}\n\ntype ToastRecord = ToastOptions & { id: ToastId; type: ToastType; message: React.ReactNode; version: number }\n\n// The toasts on screen, shared by toast() and every <Toaster />.\nlet toasts: ToastRecord[] = []\nlet count = 0\nconst listeners = new Set<() => void>()\nconst emit = () => listeners.forEach((listener) => listener())\n\nfunction show(type: ToastType, message: React.ReactNode, options: ToastOptions = {}): ToastId {\n  const id = options.id ?? `toast-${++count}`\n  const existing = toasts.find((item) => item.id === id)\n  const record: ToastRecord = { ...existing, ...options, id, type, message, version: (existing?.version ?? 0) + 1 }\n  toasts = existing ? toasts.map((item) => (item.id === id ? record : item)) : [...toasts, record]\n  emit()\n  return id\n}\n\nfunction dismiss(id?: ToastId) {\n  const gone = toasts.filter((item) => id === undefined || item.id === id)\n  toasts = id === undefined ? [] : toasts.filter((item) => item.id !== id)\n  emit()\n  gone.forEach((item) => item.onDismiss?.())\n}\n\ntype PromiseMessages<T> = {\n  loading: React.ReactNode\n  success: React.ReactNode | ((data: T) => React.ReactNode)\n  error: React.ReactNode | ((error: unknown) => React.ReactNode)\n}\n\n/**\n * Shows a toast; returns its id. toast.success, .info, .warning, .error and .loading show the type's icon,\n * toast.promise follows a promise from loading to success or error in one toast, and toast.dismiss closes one (or\n * all). Mount <Toaster /> once near the root of your app.\n */\nconst toast = Object.assign((message: React.ReactNode, options?: ToastOptions) => show(\"default\", message, options), {\n  success: (message: React.ReactNode, options?: ToastOptions) => show(\"success\", message, options),\n  info: (message: React.ReactNode, options?: ToastOptions) => show(\"info\", message, options),\n  warning: (message: React.ReactNode, options?: ToastOptions) => show(\"warning\", message, options),\n  error: (message: React.ReactNode, options?: ToastOptions) => show(\"error\", message, options),\n  loading: (message: React.ReactNode, options?: ToastOptions) => show(\"loading\", message, options),\n  promise<T>(promise: Promise<T>, messages: PromiseMessages<T>, options?: ToastOptions) {\n    const id = show(\"loading\", messages.loading, options)\n    promise.then(\n      (data) => show(\"success\", typeof messages.success === \"function\" ? messages.success(data) : messages.success, { ...options, id }),\n      (error: unknown) => show(\"error\", typeof messages.error === \"function\" ? messages.error(error) : messages.error, { ...options, id })\n    )\n    return promise\n  },\n  dismiss,\n})\n\nconst subscribe = (listener: () => void) => {\n  listeners.add(listener)\n  return () => listeners.delete(listener)\n}\nconst empty: ToastRecord[] = []\n\nconst icons: Record<Exclude<ToastType, \"default\">, React.ReactNode> = {\n  success: <CircleCheck className=\"text-success-text\" />,\n  info: <Info className=\"text-info-text\" />,\n  warning: <TriangleAlert className=\"text-warning-text\" />,\n  error: <CircleX className=\"text-destructive-text\" />,\n  loading: <Loader2 className=\"animate-spin\" />,\n}\n\ntype ToasterPosition = \"top-left\" | \"top-center\" | \"top-right\" | \"bottom-left\" | \"bottom-center\" | \"bottom-right\"\n\nconst positions: Record<ToasterPosition, string> = {\n  \"top-left\": \"top-4 start-4\",\n  \"top-center\": \"top-4 inset-x-0 mx-auto\",\n  \"top-right\": \"top-4 end-4\",\n  \"bottom-left\": \"bottom-4 start-4\",\n  \"bottom-center\": \"bottom-4 inset-x-0 mx-auto\",\n  \"bottom-right\": \"bottom-4 end-4\",\n}\n\nexport type ToasterProps = {\n  /** Where the toasts gather. */\n  position?: ToasterPosition\n  /** How many show at once; older ones wait their turn. */\n  visibleToasts?: number\n  /** How long a toast stays, in milliseconds, unless it says otherwise. */\n  duration?: number\n  className?: string\n}\n\n/** How far each older toast peeks out behind the newest while the stack is folded, and the gap when it's spread. */\nconst peek = 10\nconst gap = 8\n\n/**\n * The kit's toasts with motion. Mount <Toaster /> once near the root, then call toast() from anywhere, as with the\n * kit's. Toasts fold into a stack: the newest in front, the older ones peeking out behind it, smaller and fainter.\n * Hover (or tap) the stack and it spreads into a list on a spring, holding the timers; leave and it folds back. A new\n * toast springs in from its edge and pushes the stack back; a toast that changes (loading to done) pops its icon over\n * and crosses its words; swipe one sideways to close it, and it follows your finger. With reduced motion toasts only\n * fade, and the stack is always spread.\n */\nfunction Toaster({ position = \"bottom-right\", visibleToasts = 3, duration: life = 4000, className }: ToasterProps) {\n  const all = React.useSyncExternalStore(subscribe, () => toasts, () => empty)\n  const [hovered, setHovered] = React.useState(false)\n  const reduced = useReducedMotion()\n  const bottom = position.startsWith(\"bottom\")\n  // Newest first: the one in front.\n  const stack = all.slice(-visibleToasts).reverse()\n  const spread = hovered || reduced\n\n  // Each toast's own height, as measured: the stack is laid out from them.\n  const [heights, setHeights] = React.useState<Record<string, number>>({})\n  const measure = React.useCallback((id: ToastId, height: number) => {\n    setHeights((known) => (known[id] === height ? known : { ...known, [id]: height }))\n  }, [])\n  const heightOf = (item: ToastRecord | undefined) => (item ? (heights[String(item.id)] ?? 0) : 0)\n  const front = heightOf(stack[0])\n  // Older toasts move away from the edge: up in a bottom stack, down in a top one.\n  const away = bottom ? -1 : 1\n  let reach = 0\n  const places = stack.map((item, index) => {\n    const offset = spread ? reach : index * peek\n    reach += heightOf(item) + gap\n    return offset * away\n  })\n  const total = stack.length ? (spread ? reach - gap : front + peek * Math.min(stack.length - 1, 2)) : 0\n\n  return (\n    <section aria-label=\"Notifications\" className={cn(\"pointer-events-none fixed z-[100] w-[356px] max-w-[calc(100vw-2rem)]\", positions[position], className)}>\n      <motion.ol\n        className=\"pointer-events-auto relative\"\n        initial={false}\n        animate={{ height: total }}\n        transition={reduced ? { duration: 0 } : spring.glide}\n        onPointerEnter={() => setHovered(true)}\n        onPointerLeave={() => setHovered(false)}\n      >\n        <AnimatePresence initial={false}>\n          {stack.map((item, index) => (\n            <ToastItem\n              key={item.id}\n              item={item}\n              index={index}\n              y={places[index] ?? 0}\n              // Folded, an older toast is cut to the front one's height, so only its edge shows behind it.\n              height={!spread && index > 0 && front ? front : undefined}\n              spread={spread}\n              bottom={bottom}\n              held={hovered}\n              life={life}\n              reduced={reduced}\n              measure={measure}\n            />\n          ))}\n        </AnimatePresence>\n      </motion.ol>\n    </section>\n  )\n}\n\nfunction ToastItem({\n  item,\n  index,\n  y,\n  height,\n  spread,\n  bottom,\n  held,\n  life,\n  reduced,\n  measure,\n}: {\n  item: ToastRecord\n  index: number\n  y: number\n  height?: number\n  spread: boolean\n  bottom: boolean\n  held: boolean\n  life: number\n  reduced: boolean\n  measure: (id: ToastId, height: number) => void\n}) {\n  // Its timer: how long it has left, counting down only while the stack isn't held. A change (loading to done) starts it over.\n  const stay = item.duration ?? (item.type === \"loading\" ? Infinity : life)\n  const left = React.useRef(stay)\n  const [dragging, setDragging] = React.useState(false)\n  React.useEffect(() => {\n    left.current = stay\n  }, [stay, item.version])\n  React.useEffect(() => {\n    if (held || dragging || !Number.isFinite(left.current)) return\n    const started = Date.now()\n    const timer = setTimeout(() => dismiss(item.id), left.current)\n    return () => {\n      clearTimeout(timer)\n      left.current -= Date.now() - started\n    }\n  }, [held, dragging, item.id, item.version])\n\n  // Its natural height, for the stack's layout.\n  const body = React.useRef<HTMLDivElement>(null)\n  React.useEffect(() => {\n    const node = body.current\n    if (!node) return\n    const observer = new ResizeObserver(() => measure(item.id, node.offsetHeight))\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [item.id, measure])\n\n  // Which way it was swiped away, so it leaves that way instead of shrinking.\n  const swiped = React.useRef(0)\n  const release = (_: unknown, info: PanInfo) => {\n    setDragging(false)\n    if (Math.abs(info.offset.x) < 80 && Math.abs(info.velocity.x) < 500) return\n    swiped.current = Math.sign(info.offset.x || info.velocity.x)\n    dismiss(item.id)\n  }\n\n  const icon = item.icon ?? (item.type === \"default\" ? null : icons[item.type])\n  // Folded, each one further back is a little smaller and fainter; a fourth would be hidden behind the third.\n  const depth = spread ? 0 : index\n\n  return (\n    <motion.li\n      role=\"status\"\n      aria-live=\"polite\"\n      data-slot=\"toast\"\n      data-type={item.type}\n      className={cn(\n        \"absolute inset-x-0 w-full cursor-grab touch-pan-y overflow-hidden rounded-lg shadow-lg active:cursor-grabbing\",\n        bottom ? \"bottom-0\" : \"top-0\",\n        // Behind the front one, nothing in it can be reached until the stack spreads.\n        depth > 0 && \"pointer-events-none\"\n      )}\n      style={{ zIndex: 100 - index, transformOrigin: bottom ? \"50% 0%\" : \"50% 100%\" }}\n      initial={reduced ? { opacity: 0 } : { opacity: 0, y: (bottom ? 1 : -1) * 24, scale: 0.96 }}\n      animate={{ opacity: depth > 2 ? 0 : 1 - depth * 0.15, y, scale: 1 - depth * 0.05, height: height ?? \"auto\" }}\n      variants={{\n        out: () =>\n          reduced\n            ? { opacity: 0, transition: { duration: duration.fast } }\n            : swiped.current\n              ? { x: swiped.current * 400, opacity: 0, transition: { duration: duration.standard, ease: ease.exit } }\n              : { opacity: 0, scale: 0.96, transition: { duration: duration.fast, ease: ease.exit } },\n      }}\n      exit=\"out\"\n      transition={reduced ? { duration: duration.fast } : { ...spring.appear, height: spring.glide, opacity: { duration: duration.fast, ease: ease.enter } }}\n      drag={reduced || depth > 0 ? false : \"x\"}\n      dragSnapToOrigin\n      dragElastic={0.6}\n      onDragStart={() => setDragging(true)}\n      onDragEnd={release}\n    >\n      <div\n        ref={body}\n        className=\"flex items-start gap-2 rounded-lg border bg-popover p-4 text-sm text-popover-foreground [&_svg]:size-4 [&_svg]:shrink-0\"\n      >\n        {icon && (\n          // A new type (loading to done) pops its icon over the old one.\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={item.type}\n              className=\"flex h-5 items-center\"\n              initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={reduced ? { duration: duration.fast } : spring.pop}\n            >\n              {icon}\n            </motion.span>\n          </AnimatePresence>\n        )}\n        <div className=\"grid min-w-0 flex-1 gap-0.5 py-px\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.div\n              key={item.version}\n              className=\"grid gap-0.5\"\n              initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: duration.standard, ease: ease.enter }}\n            >\n              <p className=\"leading-5 font-medium\">{item.message}</p>\n              {item.description && <p className=\"text-muted-foreground\">{item.description}</p>}\n            </motion.div>\n          </AnimatePresence>\n        </div>\n        {item.action && (\n          // The motion Button: it presses in like every other. Its press doesn't start a swipe of the toast.\n          <Button\n            size=\"xs\"\n            className=\"shrink-0\"\n            onPointerDown={(event) => event.stopPropagation()}\n            onClick={(event) => {\n              item.action?.onClick(event)\n              dismiss(item.id)\n            }}\n          >\n            {item.action.label}\n          </Button>\n        )}\n      </div>\n    </motion.li>\n  )\n}\n\nexport { Toaster, toast }\n"}],"cssVars":{"theme":{"color-destructive-foreground":"var(--destructive-foreground)","color-destructive-text":"var(--destructive-text)","color-info":"var(--info)","color-info-foreground":"var(--info-foreground)","color-info-text":"var(--info-text)","color-success":"var(--success)","color-success-foreground":"var(--success-foreground)","color-success-text":"var(--success-text)","color-warning":"var(--warning)","color-warning-foreground":"var(--warning-foreground)","color-warning-text":"var(--warning-text)"},"light":{"destructive-foreground":"oklch(0.985 0 0)","destructive-text":"#bd2800","info":"oklch(0.5 0.134 242.749)","info-foreground":"oklch(0.985 0 0)","info-text":"oklch(0.443 0.11 240.79)","success":"oklch(0.508 0.118 165.612)","success-foreground":"oklch(0.985 0 0)","success-text":"oklch(0.432 0.095 166.913)","warning":"#f59200","warning-foreground":"oklch(0.3 0.07 60)","warning-text":"#964e00"},"dark":{"destructive-foreground":"oklch(0.2 0 0)","destructive-text":"#ff8a5c","info":"oklch(0.685 0.169 237.323)","info-foreground":"oklch(0.2 0 0)","info-text":"oklch(0.746 0.16 232.661)","success":"oklch(0.696 0.17 162.48)","success-foreground":"oklch(0.2 0.04 160)","success-text":"oklch(0.765 0.177 163.223)","warning":"#f59200","warning-foreground":"oklch(0.25 0.06 60)","warning-text":"#ffba42"}}}