{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toast-stack","type":"registry:component","title":"Toast stack","description":"Notifications that stack, fan out on hover, follow a promise in one toast, and swipe away.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/toast-stack.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * TOAST STACK: notifications that keep their place in line\n *\n *   arrive     a toast rises in from the edge through a blur and\n *              the ones before it step back into a hairline stack\n *   stack      three peek out behind the newest, each a little\n *              smaller; hover or focus fans them out into a list\n *   time       a hairline drains along the bottom; hovering or a\n *              hidden tab pauses it\n *   promise    one toast from start to finish: a spinner while it\n *              works, then the words morph and a check draws\n *              itself (or an alert, and it shakes)\n *   action     Undo and friends sit on the right\n *   dismiss    swipe it away, or the close button; the rest close\n *              up behind it\n *\n * Call toast() from anywhere; render <Toaster /> once.\n * ───────────────────────────────────────────────────────── */\n\nexport type ToastType = \"default\" | \"success\" | \"error\" | \"info\" | \"loading\";\nexport type ToastPosition = \"bottom-right\" | \"bottom-center\" | \"bottom-left\" | \"top-right\" | \"top-center\" | \"top-left\";\n\nexport interface ToastOptions {\n  description?: string;\n  type?: ToastType;\n  /** How long it stays (ms). Loading toasts stay until they settle. */\n  duration?: number;\n  action?: { label: string; onClick: () => void };\n  /** Reuse an id to update a toast in place. */\n  id?: string;\n}\n\ninterface ToastData extends ToastOptions {\n  id: string;\n  title: string;\n  type: ToastType;\n  duration: number;\n  /** Bumped when it changes, so its timer starts over. */\n  version: number;\n  dismissed: boolean;\n}\n\n/* ── The store: toast() works from anywhere, <Toaster /> renders it ── */\n\nlet toasts: ToastData[] = [];\nlet counter = 0;\nconst listeners = new Set<() => void>();\nconst emit = () => listeners.forEach((l) => l());\nconst DEFAULT_DURATION = 4000;\n\nfunction upsert(title: string, options: ToastOptions = {}): string {\n  const id = options.id ?? `t${++counter}`;\n  const existing = toasts.find((t) => t.id === id);\n  const type = options.type ?? \"default\";\n  const next: ToastData = {\n    ...existing,\n    ...options,\n    id,\n    title,\n    type,\n    duration: options.duration ?? existing?.duration ?? DEFAULT_DURATION,\n    version: (existing?.version ?? 0) + 1,\n    dismissed: false,\n  };\n  toasts = existing ? toasts.map((t) => (t.id === id ? next : t)) : [next, ...toasts];\n  emit();\n  return id;\n}\n\nexport function toast(title: string, options?: ToastOptions) {\n  return upsert(title, options);\n}\ntoast.success = (title: string, options?: Omit<ToastOptions, \"type\">) => upsert(title, { ...options, type: \"success\" });\ntoast.error = (title: string, options?: Omit<ToastOptions, \"type\">) => upsert(title, { ...options, type: \"error\" });\ntoast.info = (title: string, options?: Omit<ToastOptions, \"type\">) => upsert(title, { ...options, type: \"info\" });\ntoast.loading = (title: string, options?: Omit<ToastOptions, \"type\">) => upsert(title, { ...options, type: \"loading\" });\n/** One toast that follows a promise: loading, then success or error, morphing in place. */\ntoast.promise = <T,>(\n  promise: Promise<T>,\n  messages: { loading: string; success: string | ((value: T) => string); error: string | ((error: unknown) => string); description?: string }\n) => {\n  const id = upsert(messages.loading, { type: \"loading\", description: messages.description });\n  promise.then(\n    (value) => upsert(typeof messages.success === \"function\" ? messages.success(value) : messages.success, { id, type: \"success\" }),\n    (error) => upsert(typeof messages.error === \"function\" ? messages.error(error) : messages.error, { id, type: \"error\" })\n  );\n  return promise;\n};\ntoast.dismiss = (id?: string) => {\n  toasts = toasts.map((t) => (id === undefined || t.id === id ? { ...t, dismissed: true } : t));\n  emit();\n};\nconst remove = (id: string) => {\n  toasts = toasts.filter((t) => t.id !== id);\n  emit();\n};\nconst subscribe = (listener: () => void) => {\n  listeners.add(listener);\n  return () => listeners.delete(listener);\n};\nconst getToasts = () => toasts;\nconst getNone = () => [] as ToastData[];\n\n/* ── Rendering ── */\n\nexport interface ToasterProps extends React.HTMLAttributes<HTMLElement> {\n  position?: ToastPosition;\n  /** How many show behind the newest when stacked. */\n  visible?: number;\n  /** Keyboard shortcut that moves focus to the toasts (with Alt). */\n  hotkey?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst GAP = 10;\nconst PEEK = 10;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 240ms ${EASE}, transform 360ms ${EASE}, filter 240ms ${EASE}`,\n});\n\nfunction StatusIcon({ type, reduced }: { type: ToastType; reduced: boolean }) {\n  const layer = \"absolute inset-0 flex items-center justify-center\";\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"relative flex h-5 shrink-0 items-center justify-center\"\n      style={{ width: type === \"default\" ? 0 : 16, marginRight: type === \"default\" ? 0 : 10, transition: reduced ? \"none\" : `width 320ms ${EASE}, margin 320ms ${EASE}` }}\n    >\n      <span className={layer} style={swap(type === \"loading\", reduced)}>\n        <span className={`size-3.5 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${type === \"loading\" ? \"animate-spin\" : \"\"}`} />\n      </span>\n      <span className={`${layer} text-emerald-600 dark:text-emerald-400`} style={swap(type === \"success\", reduced)}>\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4\">\n          <path\n            d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.9\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            pathLength={1}\n            strokeDasharray={1}\n            style={{ strokeDashoffset: type === \"success\" ? 0 : 1, transition: type === \"success\" && !reduced ? `stroke-dashoffset 380ms ${EASE} 100ms` : \"none\" }}\n          />\n        </svg>\n      </span>\n      <span className={`${layer} text-red-600 dark:text-red-400`} style={swap(type === \"error\", reduced)}>\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4\">\n          <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n          <path d=\"M8 4.75v3.75\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n          <circle cx=\"8\" cy=\"11\" r=\"0.9\" fill=\"currentColor\" />\n        </svg>\n      </span>\n      <span className={`${layer} text-muted-foreground`} style={swap(type === \"info\", reduced)}>\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4\">\n          <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n          <path d=\"M8 7.25v4\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n          <circle cx=\"8\" cy=\"4.9\" r=\"0.9\" fill=\"currentColor\" />\n        </svg>\n      </span>\n    </span>\n  );\n}\n\nfunction ToastCard({\n  data,\n  index,\n  expanded,\n  frontHeight,\n  height,\n  offset,\n  top,\n  side,\n  limit,\n  paused,\n  reduced,\n  onHeight,\n}: {\n  data: ToastData;\n  index: number;\n  expanded: boolean;\n  frontHeight: number;\n  height: number;\n  offset: number;\n  top: boolean;\n  side: \"left\" | \"right\" | \"center\";\n  limit: number;\n  paused: boolean;\n  reduced: boolean;\n  onHeight: (id: string, height: number) => void;\n}) {\n  const ref = React.useRef<HTMLLIElement>(null);\n  const innerRef = React.useRef<HTMLDivElement>(null);\n  const [mounted, setMounted] = React.useState(reduced);\n  const [drag, setDrag] = React.useState<{ x: number; active: boolean }>({ x: 0, active: false });\n  const [exitX, setExitX] = React.useState(0);\n  const start = React.useRef<{ x: number; t: number } | null>(null);\n  const leaving = data.dismissed;\n  const sign = top ? 1 : -1;\n\n  // Paint below the edge first, then rise into place.\n  React.useEffect(() => {\n    if (mounted) return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setMounted(true))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [mounted]);\n\n  React.useLayoutEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const measure = () => onHeight(data.id, el.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [data.id, onHeight]);\n\n  // Leaving: run the exit, then drop it from the store.\n  React.useEffect(() => {\n    if (!leaving) return;\n    const t = window.setTimeout(() => remove(data.id), reduced ? 0 : 340);\n    return () => window.clearTimeout(t);\n  }, [leaving, data.id, reduced]);\n\n  // A failure gives a small shake.\n  const lastType = React.useRef(data.type);\n  React.useEffect(() => {\n    if (data.type === \"error\" && lastType.current !== \"error\" && !reduced)\n      innerRef.current?.animate(\n        [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n        { duration: 380, easing: \"ease-out\" }\n      );\n    lastType.current = data.type;\n  }, [data.type, reduced]);\n\n  const hiddenBehind = index >= limit;\n  const scale = expanded || reduced ? 1 : 1 - index * 0.05;\n  // Where it sits: fanned out by the heights in front of it, or stacked a rim's width behind the one in front.\n  const placed = expanded ? sign * offset : sign * index * PEEK;\n  const y = !mounted ? -sign * (frontHeight + 24) : leaving && !exitX ? placed - sign * 12 : placed;\n  const x = leaving ? exitX || 0 : drag.x;\n  const opacity = !mounted || leaving || hiddenBehind ? 0 : 1;\n  const transform = `translate3d(${x}px, ${y}px, 0) scale(${leaving && !exitX ? scale * 0.96 : scale})`;\n  // Stacked behind the newest, a toast takes the newest's height so only its rim shows; fanned out, its own.\n  const cardHeight = height ? (!expanded && index > 0 && frontHeight ? frontHeight : height) : undefined;\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    if ((e.target as HTMLElement).closest(\"button\")) return;\n    start.current = { x: e.clientX, t: performance.now() };\n    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n    setDrag({ x: 0, active: true });\n  };\n  const onPointerMove = (e: React.PointerEvent) => {\n    if (!start.current) return;\n    const dx = e.clientX - start.current.x;\n    // It follows the finger freely toward the edge, and resists the other way.\n    const toward = side === \"left\" ? -1 : 1;\n    setDrag({ x: dx * toward > 0 || side === \"center\" ? dx : dx * 0.2, active: true });\n  };\n  const onPointerUp = (e: React.PointerEvent) => {\n    if (!start.current) return;\n    const dx = e.clientX - start.current.x;\n    const speed = Math.abs(dx) / Math.max(1, performance.now() - start.current.t);\n    start.current = null;\n    if (Math.abs(dx) > 90 || (speed > 0.45 && Math.abs(dx) > 24)) {\n      setExitX(Math.sign(dx) * ((ref.current?.offsetWidth ?? 360) + 40));\n      toast.dismiss(data.id);\n    }\n    setDrag({ x: 0, active: false });\n  };\n\n  return (\n    <li\n      ref={ref}\n      data-toast={data.id}\n      role={data.type === \"error\" ? \"alert\" : \"status\"}\n      aria-live={data.type === \"error\" ? \"assertive\" : \"polite\"}\n      aria-atomic=\"true\"\n      aria-hidden={hiddenBehind && !expanded ? true : undefined}\n      tabIndex={-1}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onPointerCancel={onPointerUp}\n      className={`group/toast pointer-events-auto absolute inset-x-0 touch-pan-y select-none outline-none ${top ? \"top-0 origin-bottom\" : \"bottom-0 origin-top\"}`}\n      style={{\n        zIndex: 100 - index,\n        transform,\n        opacity,\n        filter: !mounted || (leaving && !exitX) ? \"blur(4px)\" : \"none\",\n        transition: reduced\n          ? \"none\"\n          : drag.active\n            ? \"none\"\n            : `transform ${leaving ? 340 : 520}ms ${mounted && !leaving && index === 0 ? THROW : EASE}, opacity ${leaving ? 220 : 360}ms ${EASE}, filter 360ms ${EASE}`,\n      }}\n    >\n      <div\n        className=\"relative overflow-hidden rounded-[14px] bg-popover text-popover-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n        style={{ height: cardHeight, transition: reduced ? \"none\" : `height 420ms ${EASE}` }}\n      >\n        {/* Measured as it naturally is, whatever height the card is showing it at. */}\n        <div ref={innerRef} className=\"flex min-w-0 items-start p-3.5 pr-10\" style={{ opacity: !expanded && index > 0 ? 0 : 1, transition: reduced ? \"none\" : `opacity 260ms ${EASE}` }}>\n          <StatusIcon type={data.type} reduced={reduced} />\n          <div className=\"min-w-0 flex-1\">\n            <p className=\"text-[13.5px] font-medium leading-5 text-foreground\">\n              <TextMorph animateWidth={false}>{data.title}</TextMorph>\n            </p>\n            {data.description && <p className=\"mt-0.5 text-[12.5px] leading-5 text-muted-foreground\">{data.description}</p>}\n          </div>\n          {data.action && (\n            <button\n              type=\"button\"\n              onClick={() => {\n                data.action?.onClick();\n                toast.dismiss(data.id);\n              }}\n              className={`-my-0.5 ml-3 inline-flex h-7 shrink-0 items-center rounded-full px-3 text-[12px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}\n            >\n              {data.action.label}\n            </button>\n          )}\n        </div>\n        <button\n          type=\"button\"\n          aria-label=\"Dismiss\"\n          onClick={() => toast.dismiss(data.id)}\n          className={`absolute right-2.5 top-3 flex size-6 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-[opacity,color,background-color] duration-200 hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover/toast:opacity-100 [@media(hover:none)]:opacity-100 ${FOCUS}`}\n        >\n          <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\">\n            <path d=\"M3 3l6 6M9 3l-6 6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n          </svg>\n        </button>\n        {/* The time left, draining along the bottom edge. Restarts when the toast changes. */}\n        {data.type !== \"loading\" && data.duration !== Infinity && (\n          <span\n            key={data.version}\n            aria-hidden=\"true\"\n            onAnimationEnd={() => toast.dismiss(data.id)}\n            className=\"absolute inset-x-3.5 bottom-0 h-px origin-left bg-foreground/15\"\n            style={{\n              animation: `ui-progress ${data.duration}ms linear reverse forwards`,\n              animationPlayState: paused || leaving ? \"paused\" : \"running\",\n              opacity: !expanded && index > 0 ? 0 : 1,\n            }}\n          />\n        )}\n      </div>\n    </li>\n  );\n}\n\nexport function Toaster({ position = \"bottom-right\", visible = 3, hotkey = \"t\", className = \"\", ...props }: ToasterProps) {\n  const reduced = useReducedMotion();\n  const list = React.useSyncExternalStore(subscribe, getToasts, getNone);\n  const [heights, setHeights] = React.useState<Record<string, number>>({});\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  const regionRef = React.useRef<HTMLOListElement>(null);\n\n  const top = position.startsWith(\"top\");\n  const side = position.endsWith(\"left\") ? \"left\" : position.endsWith(\"right\") ? \"right\" : \"center\";\n  const expanded = hovered || focused || reduced;\n  const paused = hovered || focused || hidden;\n\n  const onHeight = React.useCallback((id: string, h: number) => setHeights((m) => (m[id] === h ? m : { ...m, [id]: h })), []);\n\n  React.useEffect(() => {\n    const onVisibility = () => setHidden(document.visibilityState === \"hidden\");\n    onVisibility();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => document.removeEventListener(\"visibilitychange\", onVisibility);\n  }, []);\n\n  // Alt + T jumps to the newest toast; Escape from there dismisses it.\n  React.useEffect(() => {\n    if (!hotkey) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (!e.altKey || e.code !== `Key${hotkey.toUpperCase()}`) return;\n      e.preventDefault();\n      regionRef.current?.querySelector<HTMLElement>(\"li\")?.focus();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [hotkey]);\n\n  // Forget heights of toasts that are gone.\n  React.useEffect(() => {\n    setHeights((m) => {\n      const ids = new Set(list.map((t) => t.id));\n      const next = Object.fromEntries(Object.entries(m).filter(([id]) => ids.has(id)));\n      return Object.keys(next).length === Object.keys(m).length ? m : next;\n    });\n  }, [list]);\n\n  const shown = list.filter((t) => !t.dismissed);\n  const front = shown[0];\n  const frontHeight = front ? heights[front.id] ?? 0 : 0;\n  // Each toast's distance from the edge when fanned out: the heights of the ones in front of it.\n  const offsets: Record<string, number> = {};\n  let acc = 0;\n  for (const t of list) {\n    offsets[t.id] = acc;\n    if (!t.dismissed) acc += (heights[t.id] ?? 0) + GAP;\n  }\n  const stackHeight = expanded ? Math.max(0, acc - GAP) : frontHeight + Math.min(shown.length - 1, visible - 1) * PEEK;\n\n  return (\n    <section\n      aria-label=\"Notifications (Alt+T)\"\n      className={`pointer-events-none fixed z-[90] flex w-[min(380px,calc(100vw-32px))] ${top ? \"top-4\" : \"bottom-4\"} ${\n        side === \"left\" ? \"left-4\" : side === \"right\" ? \"right-4\" : \"left-1/2 -translate-x-1/2\"\n      } ${className}`}\n      {...props}\n    >\n      <ol\n        ref={regionRef}\n        tabIndex={-1}\n        onPointerEnter={() => setHovered(true)}\n        onPointerLeave={() => setHovered(false)}\n        onFocus={() => setFocused(true)}\n        onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setFocused(false)}\n        onKeyDown={(e) => {\n          if (e.key !== \"Escape\") return;\n          const id = (e.target as HTMLElement).closest<HTMLElement>(\"[data-toast]\")?.dataset.toast;\n          if (id) toast.dismiss(id);\n        }}\n        className={`relative w-full outline-none ${shown.length ? \"pointer-events-auto\" : \"\"}`}\n        style={{ height: stackHeight, transition: reduced ? \"none\" : `height 420ms ${EASE}` }}\n      >\n        {list.map((t) => {\n          const index = t.dismissed ? Math.max(0, shown.findIndex((s) => s.id === t.id)) : shown.indexOf(t);\n          return (\n            <ToastCard\n              key={t.id}\n              data={t}\n              index={index < 0 ? 0 : index}\n              expanded={expanded}\n              frontHeight={frontHeight}\n              height={heights[t.id] ?? 0}\n              offset={offsets[t.id] ?? 0}\n              top={top}\n              side={side}\n              limit={visible}\n              paused={paused}\n              reduced={reduced}\n              onHeight={onHeight}\n            />\n          );\n        })}\n      </ol>\n    </section>\n  );\n}\n"}],"css":{"@keyframes ui-progress":{"from":{"transform":"scaleX(0)"},"to":{"transform":"scaleX(1)"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/toast-stack"}