{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-stack",
  "type": "registry:ui",
  "title": "Toast stack",
  "description": "Stack short results at the edge until you reach for them.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/toast-stack/toast-stack.tsx",
      "type": "registry:component",
      "target": "@components/xzero/toast-stack/toast-stack.tsx",
      "content": "\"use client\";\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { FocusEvent, KeyboardEvent, MouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, usePresence, useReducedMotion, useTransform } from \"motion/react\";\nimport type { HTMLMotionProps, MotionProps, TargetAndTransition, Transition } from \"motion/react\";\nimport { CircleCheck, CircleX, Info, TriangleAlert, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./toast-stack.module.css\";\n\nexport type ToastType = \"success\" | \"info\" | \"warning\" | \"error\" | \"loading\";\n\nexport interface ToastAction {\n  label: string;\n  /** Runs the action. The toast closes afterwards unless the handler updates it, so an Undo can morph the toast into its result. */\n  onClick: (id: string) => void;\n}\n\nexport interface ToastOptions {\n  /** Reuse an id to update a toast in place instead of stacking a new one. */\n  id?: string;\n  type?: ToastType;\n  title: string;\n  description?: string;\n  action?: ToastAction;\n  /** Milliseconds before the toast closes itself. Defaults by type; loading toasts wait for an update. Pass Infinity to keep it until closed. */\n  duration?: number;\n}\n\nexport interface ToastStackApi {\n  /** Shows a toast and returns its id. */\n  toast: (options: ToastOptions) => string;\n  /** Morphs a toast in place: icon, copy, and height animate to the new content and its timer restarts. Pass `action: undefined` to remove the action. */\n  update: (id: string, patch: Partial<Omit<ToastOptions, \"id\">>) => void;\n  /** Dismisses one toast, or every toast when called without an id. */\n  dismiss: (id?: string) => void;\n}\n\nexport interface ToastStackProviderProps {\n  children: ReactNode;\n  /** Base lifetime in milliseconds. Warnings and errors stay 1.6 times longer. */\n  duration?: number;\n  /** Oldest toasts beyond this count are dropped from the queue. */\n  limit?: number;\n}\n\n/**\n * The viewport for a toast stack. Render it once inside a `ToastStackProvider` and call `useToastStack()` anywhere below.\n * Use it for short, non-blocking results of what someone just did: new toasts rise from the bottom edge, older ones tuck behind,\n * and hovering or focusing the stack fans it out into a list. Toasts pause while the stack is open or the tab is hidden, and swipe right to dismiss.\n */\nexport interface ToastStackProps {\n  /** Accessible name of the notification region. */\n  label?: string;\n  position?: \"bottom-right\" | \"bottom-center\" | \"bottom-left\";\n  /** Pins the stack inside the nearest positioned ancestor instead of the window, for panels and previews. */\n  contained?: boolean;\n  /** How many toasts show at once. Older ones wait behind and return as the front ones close. */\n  visibleToasts?: number;\n  /** Alt+T moves focus into the stack. */\n  hotkey?: boolean;\n  className?: string;\n}\n\ntype ToastRecord = { id: string; type: ToastType; title: string; description?: string; action?: ToastAction; duration: number; seq: number; version: number };\ntype ToastStore = ToastStackApi & { subscribe: (listener: () => void) => () => void; getSnapshot: () => ToastRecord[]; runAction: (id: string) => void };\ntype Target = { y: number; scale: number; height: number; opacity: number; content: number };\n\n/** Each tucked toast shows this many pixels above the one in front of it. */\nconst PEEK = 14;\n/** Space between toasts when the stack is open. */\nconst GAP = 12;\n/** Tucked toasts shrink by this much per step back. */\nconst STEP = .05;\n/** The card's top and bottom borders, added to the measured content height. */\nconst BORDER = 2;\n/** A swipe this far right has faded most of the way out. */\nconst SWIPE_FADE = 280;\nconst EMPTY: ToastRecord[] = [];\nconst typeLabels: Record<ToastType, string> = { success: \"Success\", info: \"Info\", warning: \"Warning\", error: \"Error\", loading: \"In progress\" };\nconst icons = { success: CircleCheck, info: Info, warning: TriangleAlert, error: CircleX };\n\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst enterEase = [...motionTokens.ease.enter] as [number, number, number, number];\nconst fade: Transition = { duration: motionTokens.duration.fast, ease: standard };\nconst enterFade: Transition = { duration: motionTokens.duration.standard, ease: enterEase };\nconst reducedFade: Transition = { duration: motionTokens.duration.fast, ease: standard };\nconst exitFast: Transition = { duration: motionTokens.duration.fast, ease: standard };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: exitFast };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst shown: TargetAndTransition = { opacity: 1, y: \"0em\", scale: 1, filter: \"blur(0px)\" };\nconst fadeOnly: MotionProps = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: reducedFade }, transition: reducedFade };\nconst textSwap: MotionProps = { initial: textIn, animate: shown, exit: textOut, transition: { duration: motionTokens.duration.standard, ease: enterEase } };\n/** Scale rides the spring; opacity and blur tween so the blur never overshoots below zero. */\nconst iconSwap: MotionProps = { initial: iconIn, animate: shown, exit: { ...iconIn, transition: exitFast }, transition: { ...motionTokens.spring.snappy, opacity: fade, filter: fade } };\n\nconst durationFor = (type: ToastType, base: number) => type === \"loading\" ? Infinity : type === \"warning\" || type === \"error\" ? base * 1.6 : base;\n/** Where a release at this velocity would come to rest, using a fast scroll deceleration. */\nconst project = (velocity: number) => velocity / 1000 * .99 / (1 - .99);\n/** Resistance past an edge: the pull keeps moving, but less and less, the way a scroll view overscrolls. */\nconst rubberBand = (distance: number, dimension: number) => (1 - 1 / (distance * .55 / dimension + 1)) * dimension;\nconst subscribeVisibility = (listener: () => void) => { document.addEventListener(\"visibilitychange\", listener); return () => document.removeEventListener(\"visibilitychange\", listener); };\nconst getServerSnapshot = () => EMPTY;\nconst focusVisible = (element: Element) => { try { return element.matches(\":focus-visible\"); } catch { return true; } };\n\n/**\n * Closed, tucked toasts take the front toast's height so their edges line up; open, each takes its own and they stack with a gap.\n * The list is the hover target and is sized to cover the gaps, so moving between open toasts never collapses the stack.\n */\nfunction layoutStack(toasts: ToastRecord[], heights: Record<string, number>, expanded: boolean, visibleToasts: number) {\n  const front = toasts.length ? heights[toasts[0]!.id] ?? 0 : 0;\n  const tops = toasts.reduce<number[]>((sum, item, index) => [...sum, sum[index]! + (index < visibleToasts ? (heights[item.id] ?? 0) + GAP : 0)], [0]);\n  const targets = toasts.map((item, index): Target => {\n    const visible = index < visibleToasts;\n    const depth = Math.min(index, visibleToasts);\n    return expanded\n      ? { y: -tops[index]!, scale: 1, height: heights[item.id] ?? 0, opacity: visible ? 1 : 0, content: 1 }\n      : { y: -PEEK * depth, scale: 1 - STEP * depth, height: index === 0 ? heights[item.id] ?? 0 : front, opacity: visible ? 1 : 0, content: index === 0 ? 1 : 0 };\n  });\n  const listHeight = !toasts.length ? 0 : expanded ? tops[toasts.length]! - GAP : front + PEEK * (Math.min(toasts.length, visibleToasts) - 1);\n  return { targets, listHeight };\n}\n\nfunction createToastStore(base: number, limit: number): ToastStore {\n  let toasts: ToastRecord[] = [];\n  let seq = 0;\n  const listeners = new Set<() => void>();\n  const commit = (next: ToastRecord[]) => { toasts = next; listeners.forEach(listener => listener()); };\n  const update: ToastStackApi[\"update\"] = (id, patch) => {\n    const found = toasts.find(item => item.id === id);\n    if (!found) return;\n    const type = patch.type ?? found.type;\n    const record: ToastRecord = { ...found, ...patch, type, duration: patch.duration ?? durationFor(type, base), version: found.version + 1 };\n    commit(toasts.map(item => item.id === id ? record : item));\n  };\n  const toast: ToastStackApi[\"toast\"] = ({ id, ...options }) => {\n    if (id && toasts.some(item => item.id === id)) { update(id, options); return id; }\n    const type = options.type ?? \"info\";\n    seq += 1;\n    const record: ToastRecord = { ...options, id: id ?? `toast-${seq}`, type, duration: options.duration ?? durationFor(type, base), seq, version: 0 };\n    commit([record, ...toasts].slice(0, limit));\n    return record.id;\n  };\n  const dismiss: ToastStackApi[\"dismiss\"] = id => {\n    if (id === undefined) { if (toasts.length) commit([]); return; }\n    if (toasts.some(item => item.id === id)) commit(toasts.filter(item => item.id !== id));\n  };\n  const runAction = (id: string) => {\n    const found = toasts.find(item => item.id === id);\n    if (!found?.action) return;\n    found.action.onClick(id);\n    if (toasts.find(item => item.id === id)?.version === found.version) dismiss(id);\n  };\n  return {\n    toast, update, dismiss, runAction,\n    subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener); }; },\n    getSnapshot: () => toasts,\n  };\n}\n\nconst ToastStackContext = createContext<ToastStore | null>(null);\n\nfunction useStore() {\n  const store = useContext(ToastStackContext);\n  if (!store) throw new Error(\"Render toast stack consumers inside <ToastStackProvider>.\");\n  return store;\n}\n\n/** Scopes a toast queue to its subtree, so two stacks on one page never share toasts. */\nexport function ToastStackProvider({ children, duration = 5000, limit = 12 }: ToastStackProviderProps) {\n  const [store] = useState(() => createToastStore(duration, limit));\n  return <ToastStackContext.Provider value={store}>{children}</ToastStackContext.Provider>;\n}\n\n/** Shows, updates, and dismisses toasts from anywhere inside the provider. `count` is how many toasts are queued. */\nexport function useToastStack(): ToastStackApi & { count: number } {\n  const store = useStore();\n  const count = useSyncExternalStore(store.subscribe, () => store.getSnapshot().length, () => 0);\n  return { toast: store.toast, update: store.update, dismiss: store.dismiss, count };\n}\n\n/** Outgoing copies are hidden from assistive tech while they fade, so the live region reads only the current text. */\nfunction Swap(props: HTMLMotionProps<\"span\">) {\n  const present = useIsPresent();\n  return <motion.span {...props} aria-hidden={present ? props[\"aria-hidden\"] : true} />;\n}\n\ntype Gesture = { pointerId: number; startX: number; startY: number; origin: number; active: boolean; samples: { t: number; x: number }[] };\n\ninterface ToastItemProps {\n  toast: ToastRecord;\n  target: Target;\n  expanded: boolean;\n  front: boolean;\n  hidden: boolean;\n  paused: boolean;\n  reduce: boolean;\n  store: ToastStore;\n  onMeasure: (id: string, height: number) => void;\n  onDragChange: (dragging: boolean) => void;\n  onTap: () => void;\n  onHandOff: (leaving: HTMLElement, keyboard: boolean) => void;\n}\n\nfunction ToastItem({ toast, target, expanded, front, hidden, paused, reduce, store, onMeasure, onDragChange, onTap, onHandOff }: ToastItemProps) {\n  const { id } = toast;\n  const [isPresent, safeToRemove] = usePresence();\n  const itemRef = useRef<HTMLLIElement>(null);\n  const cardRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const actionRef = useRef<HTMLDivElement>(null);\n  const closeRef = useRef<HTMLButtonElement>(null);\n  // Stack geometry lives in motion values, so following the stack, a finger, or a timer never re-renders React per frame.\n  const y = useMotionValue(0);\n  const scale = useMotionValue(1);\n  const height = useMotionValue(0);\n  const opacity = useMotionValue(0);\n  const contentOpacity = useMotionValue(1);\n  const x = useMotionValue(0);\n  const swipeFade = useTransform(x, value => value > 0 ? 1 - Math.min(value / SWIPE_FADE, 1) * .75 : 1);\n  /** The expanded state the last target was applied under; null until the toast has entered. */\n  const applied = useRef<boolean | null>(null);\n  const swiped = useRef(false);\n  const remaining = useRef(toast.duration);\n  const gesture = useRef<Gesture | null>(null);\n  const suppressClick = useRef(false);\n  const pointerType = useRef(\"mouse\");\n\n  // Measure before the first paint, so the toast enters from exactly its own height below the edge.\n  useLayoutEffect(() => {\n    const node = contentRef.current;\n    if (!node) return;\n    onMeasure(id, node.offsetHeight + BORDER);\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => onMeasure(id, node.offsetHeight + BORDER));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [id, onMeasure]);\n\n  // Every stack change retargets the running springs from wherever they are, so rapid toasts and hover changes never queue.\n  useLayoutEffect(() => {\n    if (!isPresent || target.height <= 0) return;\n    const entering = applied.current === null;\n    const morph = !entering && applied.current !== expanded;\n    applied.current = expanded;\n    if (reduce) {\n      y.jump(target.y); scale.jump(target.scale); height.jump(target.height);\n      animate(opacity, target.opacity, reducedFade);\n      animate(contentOpacity, target.content, reducedFade);\n      return;\n    }\n    if (entering) { y.jump(target.y + target.height); scale.jump(target.scale); height.jump(target.height); contentOpacity.jump(target.content); opacity.jump(0); }\n    const spring = morph ? motionTokens.spring.morph : motionTokens.spring.smooth;\n    animate(y, target.y, spring);\n    animate(scale, target.scale, spring);\n    if (!entering) animate(height, target.height, spring);\n    animate(opacity, target.opacity, entering ? enterFade : fade);\n    animate(contentOpacity, target.content, fade);\n  }, [contentOpacity, expanded, height, isPresent, opacity, reduce, scale, target.content, target.height, target.opacity, target.scale, target.y, y]);\n\n  // An update restarts the clock; pausing keeps whatever time is left.\n  useEffect(() => { remaining.current = toast.duration; }, [toast.version, toast.duration]);\n  useEffect(() => {\n    if (!isPresent || paused || !Number.isFinite(remaining.current) || toast.duration <= 0) return;\n    const started = performance.now();\n    const timer = window.setTimeout(() => store.dismiss(id), Math.max(remaining.current, 0));\n    return () => { window.clearTimeout(timer); remaining.current -= performance.now() - started; };\n  }, [id, isPresent, paused, store, toast.version, toast.duration]);\n\n  // Leaving: hand focus on before the toast turns inert, then fade out quickly. A closed toast sinks toward its edge; a thrown one keeps flying.\n  useEffect(() => {\n    if (isPresent) return;\n    const node = itemRef.current;\n    if (node) {\n      const active = document.activeElement;\n      if (active instanceof HTMLElement && node.contains(active)) onHandOff(node, focusVisible(active));\n      node.setAttribute(\"inert\", \"\");\n    }\n    const done = () => safeToRemove?.();\n    if (reduce) { animate(opacity, 0, reducedFade).then(done); return; }\n    if (!swiped.current) {\n      animate(y, y.get() + 10, motionTokens.spring.smooth);\n      animate(scale, scale.get() * .96, motionTokens.spring.smooth);\n    }\n    animate(opacity, 0, { duration: swiped.current ? .22 : motionTokens.duration.exit, ease: standard }).then(done);\n  }, [isPresent, onHandOff, opacity, reduce, safeToRemove, scale, y]);\n\n  function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {\n    pointerType.current = event.pointerType;\n    suppressClick.current = false;\n    if (event.button !== 0 || !isPresent || (event.target as Element).closest(\"button, a\")) return;\n    // Catch a toast that is still springing back, from exactly where it is.\n    x.stop();\n    gesture.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, origin: x.get(), active: false, samples: [] };\n  }\n\n  function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {\n    const current = gesture.current;\n    if (!current || current.pointerId !== event.pointerId) return;\n    if (!current.active) {\n      const dx = event.clientX - current.startX, dy = event.clientY - current.startY;\n      if (Math.hypot(dx, dy) < 6) return;\n      if (Math.abs(dy) > Math.abs(dx)) { gesture.current = null; return; }\n      // Start following from here, so crossing the slop never makes the toast jump.\n      current.active = true;\n      current.startX = event.clientX;\n      suppressClick.current = true;\n      event.currentTarget.setPointerCapture(event.pointerId);\n      event.currentTarget.dataset.dragging = \"\";\n      window.getSelection()?.removeAllRanges();\n      onDragChange(true);\n    }\n    const raw = current.origin + event.clientX - current.startX;\n    // Right follows the finger 1:1; left is the wrong way, so it resists like an overscroll.\n    x.set(raw >= 0 ? raw : -rubberBand(-raw, event.currentTarget.offsetWidth));\n    current.samples.push({ t: event.timeStamp, x: raw });\n    while (current.samples.length > 2 && event.timeStamp - current.samples[0]!.t > 100) current.samples.shift();\n  }\n\n  function onPointerEnd(event: ReactPointerEvent<HTMLDivElement>, cancelled: boolean) {\n    const current = gesture.current;\n    if (!current || current.pointerId !== event.pointerId) return;\n    gesture.current = null;\n    if (!current.active) return;\n    delete event.currentTarget.dataset.dragging;\n    onDragChange(false);\n    const first = current.samples[0], last = current.samples[current.samples.length - 1];\n    // A finger that stopped before lifting carries no throw.\n    const velocity = cancelled || !first || !last || last.t === first.t || event.timeStamp - last.t > 60 ? 0 : (last.x - first.x) / (last.t - first.t) * 1000;\n    const width = event.currentTarget.offsetWidth;\n    const offset = x.get();\n    if (!cancelled && offset > 0 && velocity > -200 && offset + project(velocity) > width * .4) {\n      swiped.current = true;\n      if (!reduce) animate(x, width + 40, { type: \"spring\", visualDuration: .32, bounce: 0, velocity: Math.max(velocity, 0) });\n      store.dismiss(id);\n      return;\n    }\n    if (reduce) x.jump(0);\n    else animate(x, 0, { ...motionTokens.spring.snappy, velocity: offset < 0 ? velocity * .3 : velocity });\n  }\n\n  function onCardClick(event: MouseEvent<HTMLDivElement>) {\n    if (suppressClick.current) { suppressClick.current = false; return; }\n    // Touch has no hover, so a tap on the card opens or closes the stack instead.\n    if (pointerType.current !== \"mouse\" && !(event.target as Element).closest(\"button, a\")) onTap();\n  }\n\n  // An action that updates its toast usually folds itself away; keyboard focus lands on the close button instead of the page.\n  function runAction(event: MouseEvent<HTMLButtonElement>) {\n    const keyboard = focusVisible(event.currentTarget);\n    store.runAction(id);\n    if (keyboard && !store.getSnapshot().find(item => item.id === id)?.action) closeRef.current?.focus();\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLLIElement>) {\n    if (event.key !== \"Escape\") return;\n    event.stopPropagation();\n    store.dismiss(id);\n  }\n\n  const markMoving = (moving: boolean) => { if (actionRef.current) actionRef.current.dataset.moving = moving ? \"true\" : \"false\"; };\n  const Icon = toast.type === \"loading\" ? null : icons[toast.type];\n  const swap = reduce ? fadeOnly : textSwap;\n\n  return <motion.li ref={itemRef} className={styles.item} style={{ y, scale, height, opacity, zIndex: toast.seq }} data-front={front} data-expanded={expanded} inert={hidden} onKeyDown={onKeyDown}>\n    <motion.div ref={cardRef} className={styles.card} style={{ x, opacity: swipeFade }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={event => onPointerEnd(event, false)} onPointerCancel={event => onPointerEnd(event, true)} onClick={onCardClick}>\n      <motion.div ref={contentRef} className={styles.content} style={{ opacity: contentOpacity }}>\n        {/* A new type morphs its icon in place: the old glyph shrinks into a blur while the new one grows out of it. */}\n        <span className={styles.icon} data-type={toast.type} aria-hidden=\"true\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <Swap key={toast.type} className={styles.glyph} {...(reduce ? fadeOnly : iconSwap)}>{Icon ? <Icon width={18} height={18} strokeWidth={1.75} /> : <span className={styles.spinner} />}</Swap>\n          </AnimatePresence>\n        </span>\n        <div className={styles.copy}>\n          <span className={styles.srOnly}>{typeLabels[toast.type]}: </span>\n          <span className={styles.title}><AnimatePresence mode=\"popLayout\" initial={false}><Swap key={toast.title} className={styles.line} {...swap}>{toast.title}</Swap></AnimatePresence></span>\n          <AnimatePresence mode=\"popLayout\" initial={false}>{toast.description ? <Swap key={toast.description} className={styles.description} {...swap}>{toast.description}</Swap> : null}</AnimatePresence>\n        </div>\n        {/* The action folds its width away when an update removes it, so the copy widens instead of snapping. */}\n        <AnimatePresence initial={false}>\n          {toast.action ? <motion.div key=\"action\" ref={actionRef} className={styles.actionSlot} initial={reduce ? { opacity: 0 } : { width: 0, opacity: 0 }} animate={{ width: \"auto\", opacity: 1 }} exit={reduce ? { opacity: 0 } : { width: 0, opacity: 0 }} transition={reduce ? reducedFade : { width: motionTokens.spring.smooth, opacity: fade }} onAnimationStart={() => markMoving(true)} onAnimationComplete={() => markMoving(false)}>\n            <button type=\"button\" className={styles.action} onClick={runAction}>{toast.action.label}</button>\n          </motion.div> : null}\n        </AnimatePresence>\n        <button ref={closeRef} type=\"button\" className={styles.close} aria-label=\"Dismiss notification\" onClick={() => store.dismiss(id)}>\n          <X width={16} height={16} strokeWidth={1.75} aria-hidden=\"true\" />\n        </button>\n      </motion.div>\n    </motion.div>\n  </motion.li>;\n}\n\nexport function ToastStack({ label = \"Notifications\", position = \"bottom-right\", contained = false, visibleToasts = 3, hotkey = true, className }: ToastStackProps) {\n  const store = useStore();\n  const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot);\n  const reduce = !!useReducedMotion();\n  const pageHidden = useSyncExternalStore(subscribeVisibility, () => document.visibilityState === \"hidden\", () => false);\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [tapped, setTapped] = useState(false);\n  const [dragging, setDragging] = useState(false);\n  const [heights, setHeights] = useState<Record<string, number>>({});\n  const regionRef = useRef<HTMLElement>(null);\n  const listRef = useRef<HTMLOListElement>(null);\n  const returnFocus = useRef<HTMLElement | null>(null);\n  if (tapped && toasts.length === 0) setTapped(false);\n  const expanded = toasts.length > 0 && (hovered || focused || tapped);\n  const paused = expanded || dragging || pageHidden;\n\n  const measure = useCallback((id: string, height: number) => setHeights(current => current[id] === height ? current : { ...current, [id]: height }), []);\n  const prune = useCallback(() => setHeights(current => Object.fromEntries(store.getSnapshot().filter(item => item.id in current).map(item => [item.id, current[item.id]!]))), [store]);\n  const toggleTap = useCallback(() => setTapped(open => !open), []);\n\n  /** Keyboard users land on the next toast; with none left they return to where they were before entering the stack. */\n  const handOff = useCallback((leaving: HTMLElement, keyboard: boolean) => {\n    const items = Array.from(listRef.current?.children ?? []) as HTMLElement[];\n    const at = items.indexOf(leaving);\n    const candidates = items.filter(item => item !== leaving && !item.hasAttribute(\"inert\"));\n    const next = candidates.find(item => items.indexOf(item) > at) ?? candidates[candidates.length - 1];\n    const button = next?.querySelector<HTMLElement>(\"button\");\n    if (keyboard && button) button.focus();\n    else if (keyboard && returnFocus.current?.isConnected) returnFocus.current.focus();\n    else (document.activeElement as HTMLElement | null)?.blur();\n  }, []);\n\n  useEffect(() => {\n    if (!hotkey) return;\n    const onKey = (event: globalThis.KeyboardEvent) => {\n      if (!event.altKey || event.metaKey || event.ctrlKey || event.code !== \"KeyT\") return;\n      const target = listRef.current?.querySelector<HTMLElement>(\":scope > li:not([inert]) button\");\n      if (!target) return;\n      event.preventDefault();\n      // Browsers skip the focus ring after a modifier shortcut, so the hotkey asks for it and opens the stack itself.\n      target.focus({ focusVisible: true } as FocusOptions);\n      setFocused(true);\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [hotkey]);\n\n  useEffect(() => {\n    if (!tapped) return;\n    const onDown = (event: PointerEvent) => { if (!regionRef.current?.contains(event.target as Node)) setTapped(false); };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [tapped]);\n\n  const { targets, listHeight } = layoutStack(toasts, heights, expanded, visibleToasts);\n\n  function onFocus(event: FocusEvent<HTMLElement>) {\n    const from = event.relatedTarget;\n    if (!(from instanceof Node) || !event.currentTarget.contains(from)) returnFocus.current = from instanceof HTMLElement ? from : null;\n    // Only keyboard focus opens the stack; a mouse click on a button should not pin it open.\n    if (focusVisible(event.target)) setFocused(true);\n  }\n\n  function onBlur(event: FocusEvent<HTMLElement>) {\n    if (!(event.relatedTarget instanceof Node) || !event.currentTarget.contains(event.relatedTarget)) setFocused(false);\n  }\n\n  return <section ref={regionRef} className={[styles.viewport, styles[position], contained && styles.contained, className].filter(Boolean).join(\" \")} aria-label={hotkey ? `${label} (Alt+T)` : label} aria-live=\"polite\" aria-relevant=\"additions text\" aria-atomic=\"false\" onFocus={onFocus} onBlur={onBlur}>\n    <ol ref={listRef} className={styles.list} style={{ height: listHeight }} data-expanded={expanded} onPointerEnter={event => { if (event.pointerType === \"mouse\") setHovered(true); }} onPointerMove={event => { if (event.pointerType === \"mouse\" && !hovered) setHovered(true); }} onPointerLeave={event => { if (event.pointerType === \"mouse\") setHovered(false); }}>\n      <AnimatePresence onExitComplete={prune}>\n        {toasts.map((item, index) => <ToastItem key={item.id} toast={item} target={targets[index]!} expanded={expanded} front={index === 0} hidden={index >= visibleToasts} paused={paused} reduce={reduce} store={store} onMeasure={measure} onDragChange={setDragging} onTap={toggleTap} onHandOff={handOff} />)}\n      </AnimatePresence>\n    </ol>\n  </section>;\n}\n\nexport default ToastStack;\n"
    },
    {
      "path": "registry/components/toast-stack/toast-stack.module.css",
      "type": "registry:component",
      "target": "@components/xzero/toast-stack/toast-stack.module.css",
      "content": "/* The region only holds the list; it never blocks clicks outside the toasts themselves. */\n.viewport { position: fixed; z-index: 60; bottom: var(--space-6); width: min(22.5rem, calc(100% - var(--space-8))); pointer-events: none; }\n.bottom-right { right: var(--space-6); }\n.bottom-left { left: var(--space-6); }\n.bottom-center { right: 0; left: 0; margin-inline: auto; }\n.contained { position: absolute; z-index: 2; bottom: var(--space-4); }\n.contained.bottom-right { right: var(--space-4); }\n.contained.bottom-left { left: var(--space-4); }\n@media (max-width: 30rem) { .viewport:not(.contained) { right: 0; bottom: var(--space-4); left: 0; margin-inline: auto; } }\n\n/* Toasts hang from the bottom of the list. Its height covers the gaps between open toasts, so hover survives the space between them. */\n.list { position: relative; margin: 0; padding: 0; list-style: none; pointer-events: auto; }\n/* Tucked toasts scale from their top edge, so exactly the peek shows above the toast in front. */\n.item { position: absolute; right: 0; bottom: 0; left: 0; transform-origin: 50% 0; }\n.card { position: absolute; inset: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-raised); container-type: inline-size; touch-action: pan-y; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard); }\n.item[data-front=\"true\"] .card, .item[data-expanded=\"true\"] .card { box-shadow: var(--shadow-floating); }\n/* Tucked toasts sit a step back in tone as well as scale. */\n.item[data-front=\"false\"][data-expanded=\"false\"] .card { background: var(--surface-muted); }\n.card[data-dragging] { cursor: grabbing; user-select: none; }\n\n.content { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4); }\n.icon { position: relative; display: grid; width: 18px; height: 20px; flex: none; place-items: center; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }\n.icon[data-type=\"success\"] { color: var(--success); }\n.icon[data-type=\"info\"] { color: var(--accent); }\n.icon[data-type=\"warning\"] { color: var(--warning); }\n.icon[data-type=\"error\"] { color: var(--danger); }\n.glyph { grid-area: 1 / 1; display: grid; place-items: center; }\n.glyph svg { display: block; }\n/* The spinner turns only while something is loading; the icon swap is its stop. */\n.spinner { display: block; width: 15px; height: 15px; border: 1.75px solid currentColor; border-right-color: transparent; border-radius: var(--radius-pill); animation: spin .8s linear infinite; }\n\n.copy { position: relative; min-width: 0; flex: 1; font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: 20px; }\n.title { position: relative; display: block; font-weight: 500; }\n.line { display: block; }\n.description { display: block; margin-top: 2px; color: var(--text-secondary); }\n\n/* Clips only while its width folds, so the focus ring stays whole at rest. */\n.actionSlot { display: flex; flex: none; justify-content: flex-end; margin-top: -4px; }\n.actionSlot[data-moving=\"true\"] { overflow: hidden; }\n.action, .close { flex: none; border: 1px solid transparent; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: scale var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }\n.action { height: 28px; padding: 0 var(--space-3); border-color: var(--border); border-radius: var(--radius-pill); background: var(--surface); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; white-space: nowrap; }\n.close { display: grid; width: 28px; height: 28px; margin: -4px -2px 0 0; place-items: center; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); }\n@media (hover: hover) and (pointer: fine) {\n  .action:hover { border-color: var(--border-strong); background: var(--surface-muted); }\n  .close:hover { background: var(--surface-muted); color: var(--foreground); }\n}\n/* Quick press, spring release. */\n.action:active, .close:active { scale: .96; transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }\n/* A narrow toast keeps its copy wide: the action drops under the copy instead of squeezing it. */\n@container (max-width: 21rem) {\n  .content { flex-wrap: wrap; row-gap: var(--space-2); }\n  .actionSlot { order: 1; flex-basis: 100%; justify-content: flex-start; margin-top: 0; padding-left: calc(18px + var(--space-3)); }\n}\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@keyframes spin { to { rotate: 360deg; } }\n@media (prefers-reduced-motion: reduce) {\n  .card, .icon, .action, .close { transition: none; }\n  .action:active, .close:active { scale: none; }\n  .spinner { animation: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/toast-stack",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "toast",
      "notification",
      "sonner"
    ],
    "docs": "https://ui.xzero.co.uk/components/toast-stack",
    "markdown": "https://ui.xzero.co.uk/components/toast-stack/markdown"
  }
}