{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toast-stack","type":"registry:component","title":"Toast stack","description":"Stacks with depth, fans out on hover, and each toast's edge drains as its timer.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/toast-stack.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ntype Toast = { id: number; title: string; description: string };\n\nconst HEIGHT = 64;\nconst GAP = 8;\n// Collapsed, each toast behind sits this much higher and 5% smaller, enough\n// to read as a pile without hiding the front one.\nconst PEEK = 12;\nconst SHRINK = 0.05;\nconst VISIBLE = 3;\nconst DURATION = 4000;\n// Long enough for the leave transition below to finish before unmounting.\nconst LEAVE_MS = 200;\nconst FLICK_VELOCITY = 0.11;\nconst DISMISS_DISTANCE = 40;\n\n// The toast's own edge is its timer: a hairline that drains around the\n// outline, clockwise from the top left, and stops the moment you hover or\n// switch tabs, because the dismiss timers above stop then too. Linear,\n// because it is a clock. A keyframe, since it runs once per toast and only\n// ever pauses, never retargets.\nconst CSS = `\n@keyframes toast-drain {\n  from { stroke-dashoffset: 0; }\n  to { stroke-dashoffset: -1; }\n}\n.toast-drain { animation: toast-drain ${DURATION}ms linear forwards; }\n`;\n\nexport function ToastStack({\n  toasts,\n  onDismiss,\n}: {\n  toasts: Toast[];\n  onDismiss: (id: number) => void;\n}) {\n  const [hovered, setHovered] = useState(false);\n  const [hidden, setHidden] = useState(false);\n  const [leaving, setLeaving] = useState<Map<number, number>>(new Map());\n  const remaining = useRef(new Map<number, number>());\n\n  const dismiss = useCallback(\n    (id: number, index: number) => {\n      setLeaving((m) => new Map(m).set(id, index));\n      setTimeout(() => {\n        onDismiss(id);\n        setLeaving((m) => {\n          const next = new Map(m);\n          next.delete(id);\n          return next;\n        });\n        remaining.current.delete(id);\n      }, LEAVE_MS);\n    },\n    [onDismiss],\n  );\n\n  useEffect(() => {\n    const onChange = () => setHidden(document.hidden);\n    document.addEventListener(\"visibilitychange\", onChange);\n    return () => document.removeEventListener(\"visibilitychange\", onChange);\n  }, []);\n\n  // Newest first. Toasts on their way out keep the slot they left from.\n  const live = useMemo(\n    () => toasts.filter((t) => !leaving.has(t.id)).reverse(),\n    [toasts, leaving],\n  );\n\n  // Timers pause while you're reading the stack or looking at another tab,\n  // so a toast never disappears unseen.\n  useEffect(() => {\n    if (hovered || hidden) return;\n    const left = remaining.current;\n    const started = performance.now();\n    const timers = live.map((t, i) =>\n      setTimeout(() => dismiss(t.id, i), left.get(t.id) ?? DURATION),\n    );\n    return () => {\n      timers.forEach(clearTimeout);\n      const spent = performance.now() - started;\n      for (const t of live) {\n        left.set(t.id, (left.get(t.id) ?? DURATION) - spent);\n      }\n    };\n  }, [live, hovered, hidden, dismiss]);\n\n  const expandedHeight = live.length * (HEIGHT + GAP) - GAP;\n\n  return (\n    <section\n      aria-label=\"Notifications\"\n      aria-live=\"polite\"\n      className=\"fixed bottom-6 left-1/2 z-50 w-[356px] max-w-[calc(100vw-2rem)] -translate-x-1/2\"\n    >\n      <style href=\"toast-stack\" precedence=\"default\">\n        {CSS}\n      </style>\n      {/* Grows with the fanned-out stack, so moving between toasts never\n          leaves the hover area and collapses them. */}\n      <ol\n        className=\"relative\"\n        style={{ height: hovered ? Math.max(expandedHeight, HEIGHT) : HEIGHT }}\n        onPointerEnter={(e) => {\n          if (e.pointerType !== \"touch\") setHovered(true);\n        }}\n        onPointerLeave={() => setHovered(false)}\n      >\n        {toasts.map((toast) => {\n          const leaveIndex = leaving.get(toast.id);\n          const index = leaveIndex ?? live.indexOf(toast);\n          return (\n            <ToastItem\n              key={toast.id}\n              toast={toast}\n              index={index}\n              expanded={hovered}\n              paused={hovered || hidden}\n              leaving={leaveIndex !== undefined}\n              onDismiss={() => dismiss(toast.id, index)}\n            />\n          );\n        })}\n      </ol>\n    </section>\n  );\n}\n\nfunction ToastItem({\n  toast,\n  index,\n  expanded,\n  paused,\n  leaving,\n  onDismiss,\n}: {\n  toast: Toast;\n  index: number;\n  expanded: boolean;\n  paused: boolean;\n  leaving: boolean;\n  onDismiss: () => void;\n}) {\n  const drag = useRef<{\n    startX: number;\n    startY: number;\n    startTime: number;\n    axis: \"x\" | \"y\" | null;\n    distance: number;\n  }>(null);\n\n  const y = expanded ? -index * (HEIGHT + GAP) : -index * PEEK;\n  const scale = expanded ? 1 : 1 - index * SHRINK;\n  const opacity = leaving || index >= VISIBLE ? 0 : 1;\n\n  return (\n    <li\n      className={cn(\n        \"absolute inset-x-0 bottom-0 flex h-16 touch-none flex-col justify-center rounded-2xl bg-background px-4 shadow-raised select-none\",\n        // Enters from below. Transitions rather than keyframes, so a toast\n        // added mid-animation retargets the stack instead of restarting it.\n        \"[transform:translateY(var(--y))_scale(var(--scale))] opacity-(--opacity) transition-[transform,opacity,translate,filter] duration-400 ease-[ease] starting:[transform:translateY(100%)] starting:opacity-0\",\n        // Exits are quicker and softer than entrances, and only the toast\n        // that's leaving blurs.\n        leaving && \"blur-[4px] duration-200 ease-out\",\n        \"motion-reduce:transition-[opacity]\",\n      )}\n      style={\n        {\n          \"--y\": `${y + (leaving ? 12 : 0)}px`,\n          \"--scale\": scale,\n          \"--opacity\": opacity,\n          zIndex: 100 - index,\n        } as React.CSSProperties\n      }\n      onPointerDown={(e) => {\n        if (e.button !== 0 || leaving) return;\n        drag.current = {\n          startX: e.clientX,\n          startY: e.clientY,\n          startTime: performance.now(),\n          axis: null,\n          distance: 0,\n        };\n        e.currentTarget.setPointerCapture(e.pointerId);\n        e.currentTarget.style.transitionProperty = \"transform, opacity\";\n      }}\n      onPointerMove={(e) => {\n        const d = drag.current;\n        if (!d) return;\n        const dx = e.clientX - d.startX;\n        const dy = e.clientY - d.startY;\n        // Commit to one axis after a few pixels, so a slightly diagonal\n        // swipe doesn't wobble between the two.\n        if (!d.axis) {\n          if (Math.hypot(dx, dy) < 4) return;\n          d.axis = Math.abs(dx) > Math.abs(dy) ? \"x\" : \"y\";\n        }\n        if (d.axis === \"x\") {\n          d.distance = dx;\n          e.currentTarget.style.translate = `${dx}px 0`;\n        } else {\n          // Toasts leave downwards; pulling up gives only a little.\n          d.distance = dy < 0 ? -Math.pow(-dy, 0.5) : dy;\n          e.currentTarget.style.translate = `0 ${d.distance}px`;\n        }\n      }}\n      onPointerUp={(e) => {\n        const d = drag.current;\n        if (!d) return;\n        drag.current = null;\n        e.currentTarget.style.transitionProperty = \"\";\n        // Sideways works in both directions; vertically only down dismisses.\n        const reach = d.axis === \"x\" ? Math.abs(d.distance) : d.distance;\n        const velocity = reach / (performance.now() - d.startTime);\n        if (reach > DISMISS_DISTANCE || velocity > FLICK_VELOCITY) {\n          // Thrown sideways, it carries on out the way it was going.\n          if (d.axis === \"x\") {\n            e.currentTarget.style.translate = `${Math.sign(d.distance) * 100}% 0`;\n          }\n          onDismiss();\n        } else {\n          e.currentTarget.style.translate = \"\";\n        }\n      }}\n      onPointerCancel={(e) => {\n        drag.current = null;\n        e.currentTarget.style.transitionProperty = \"\";\n        e.currentTarget.style.translate = \"\";\n      }}\n    >\n      <svg\n        aria-hidden\n        className=\"pointer-events-none absolute inset-0 size-full overflow-visible text-foreground\"\n      >\n        {/* Traces the li's own 16px corners, half a pixel in so the 1px\n            stroke sits inside the edge rather than straddling it. */}\n        <rect\n          x=\"0.5\"\n          y=\"0.5\"\n          rx=\"15.5\"\n          style={{\n            width: \"calc(100% - 1px)\",\n            height: \"calc(100% - 1px)\",\n            animationPlayState: paused ? \"paused\" : \"running\",\n          }}\n          pathLength={1}\n          strokeDasharray=\"1 1\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeOpacity={0.35}\n          className=\"toast-drain\"\n        />\n      </svg>\n      <p className=\"text-sm font-medium\">{toast.title}</p>\n      <p className=\"truncate text-sm text-muted-foreground\">{toast.description}</p>\n    </li>\n  );\n}\n\nconst MESSAGES = [\n  { title: \"Saved to drafts\", description: \"You can pick this up any time.\" },\n  { title: \"Link copied\", description: \"lab.xevrion.dev/toast-stack\" },\n  { title: \"Invite sent\", description: \"They'll get an email in a moment.\" },\n  { title: \"Changes published\", description: \"Live for everyone now.\" },\n  { title: \"File uploaded\", description: \"notes.md, 12 KB\" },\n];\n\nexport default function ToastStackDemo() {\n  const [toasts, setToasts] = useState<Toast[]>([]);\n  const nextId = useRef(0);\n\n  const add = () => {\n    const id = nextId.current++;\n    const message = MESSAGES[id % MESSAGES.length];\n    // Anything past the visible three is already transparent, so older\n    // toasts can go without an exit.\n    setToasts((ts) => [...ts.slice(-(VISIBLE + 1)), { id, ...message }]);\n  };\n\n  const remove = useCallback(\n    (id: number) => setToasts((ts) => ts.filter((t) => t.id !== id)),\n    [],\n  );\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        onClick={add}\n        className=\"h-10 rounded-full bg-muted px-4 text-sm font-medium shadow-raised transition-[scale] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-none\"\n      >\n        Add toast\n      </button>\n      <ToastStack toasts={toasts} onDismiss={remove} />\n    </>\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/toast-stack"}