{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/toast-stack/toast-stack.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type { FocusEvent, KeyboardEvent, MouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  usePresence,\n  useReducedMotion,\n  useTransform,\n} 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 { cn } from \"../lib/utils\";\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 = {\n  id: string;\n  type: ToastType;\n  title: string;\n  description?: string;\n  action?: ToastAction;\n  duration: number;\n  seq: number;\n  version: number;\n};\ntype ToastStore = ToastStackApi & {\n  subscribe: (listener: () => void) => () => void;\n  getSnapshot: () => ToastRecord[];\n  runAction: (id: string) => void;\n};\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 = 0.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> = {\n  success: \"Success\",\n  info: \"Info\",\n  warning: \"Warning\",\n  error: \"Error\",\n  loading: \"In progress\",\n};\nconst icons = { success: CircleCheck, info: Info, warning: TriangleAlert, error: CircleX };\nconst typeColor: Record<ToastType, string> = {\n  success: \"text-green\",\n  info: \"text-accent\",\n  warning: \"text-orange\",\n  error: \"text-red\",\n  loading: \"text-ink-3\",\n};\n/** Quick press, spring release: the resting transition carries the spring back after the press. */\nconst pressable =\n  \"flex-none cursor-pointer border [-webkit-tap-highlight-color:transparent] transition-[scale,background-color,border-color,color] duration-[var(--duration-spring),var(--duration-fast),var(--duration-fast),var(--duration-fast)] ease-[var(--ease-spring),var(--ease-standard),var(--ease-standard),var(--ease-standard)] active:scale-96 active:duration-instant active:ease-standard motion-reduce:transition-none motion-reduce:active:scale-none\";\nconst viewportAt = {\n  window: { \"bottom-right\": \"right-6\", \"bottom-left\": \"left-6\", \"bottom-center\": \"inset-x-0 mx-auto\" },\n  contained: { \"bottom-right\": \"right-4\", \"bottom-left\": \"left-4\", \"bottom-center\": \"inset-x-0 mx-auto\" },\n} as const;\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 = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: exitFast,\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst shown: TargetAndTransition = { opacity: 1, y: \"0em\", scale: 1, filter: \"blur(0px)\" };\nconst fadeOnly: MotionProps = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0, transition: reducedFade },\n  transition: reducedFade,\n};\nconst textSwap: MotionProps = {\n  initial: textIn,\n  animate: shown,\n  exit: textOut,\n  transition: { duration: motionTokens.duration.standard, ease: enterEase },\n};\n/** Scale rides the spring; opacity and blur tween so the blur never overshoots below zero. */\nconst iconSwap: MotionProps = {\n  initial: iconIn,\n  animate: shown,\n  exit: { ...iconIn, transition: exitFast },\n  transition: { ...motionTokens.spring.snappy, opacity: fade, filter: fade },\n};\n\nconst durationFor = (type: ToastType, base: number) =>\n  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) * 0.99) / (1 - 0.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 * 0.55) / dimension + 1)) * dimension;\nconst subscribeVisibility = (listener: () => void) => {\n  document.addEventListener(\"visibilitychange\", listener);\n  return () => document.removeEventListener(\"visibilitychange\", listener);\n};\nconst getServerSnapshot = () => EMPTY;\nconst focusVisible = (element: Element) => {\n  try {\n    return element.matches(\":focus-visible\");\n  } catch {\n    return true;\n  }\n};\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[]>(\n    (sum, item, index) => [...sum, sum[index]! + (index < visibleToasts ? (heights[item.id] ?? 0) + GAP : 0)],\n    [0],\n  );\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      : {\n          y: -PEEK * depth,\n          scale: 1 - STEP * depth,\n          height: index === 0 ? (heights[item.id] ?? 0) : front,\n          opacity: visible ? 1 : 0,\n          content: index === 0 ? 1 : 0,\n        };\n  });\n  const listHeight = !toasts.length\n    ? 0\n    : expanded\n      ? tops[toasts.length]! - GAP\n      : 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[]) => {\n    toasts = next;\n    listeners.forEach((listener) => listener());\n  };\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 = {\n      ...found,\n      ...patch,\n      type,\n      duration: patch.duration ?? durationFor(type, base),\n      version: found.version + 1,\n    };\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)) {\n      update(id, options);\n      return id;\n    }\n    const type = options.type ?? \"info\";\n    seq += 1;\n    const record: ToastRecord = {\n      ...options,\n      id: id ?? `toast-${seq}`,\n      type,\n      duration: options.duration ?? durationFor(type, base),\n      seq,\n      version: 0,\n    };\n    commit([record, ...toasts].slice(0, limit));\n    return record.id;\n  };\n  const dismiss: ToastStackApi[\"dismiss\"] = (id) => {\n    if (id === undefined) {\n      if (toasts.length) commit([]);\n      return;\n    }\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,\n    update,\n    dismiss,\n    runAction,\n    subscribe: (listener) => {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\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(\n    store.subscribe,\n    () => store.getSnapshot().length,\n    () => 0,\n  );\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 = {\n  pointerId: number;\n  startX: number;\n  startY: number;\n  origin: number;\n  active: boolean;\n  samples: { t: number; x: number }[];\n};\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({\n  toast,\n  target,\n  expanded,\n  front,\n  hidden,\n  paused,\n  reduce,\n  store,\n  onMeasure,\n  onDragChange,\n  onTap,\n  onHandOff,\n}: 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) * 0.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);\n      scale.jump(target.scale);\n      height.jump(target.height);\n      animate(opacity, target.opacity, reducedFade);\n      animate(contentOpacity, target.content, reducedFade);\n      return;\n    }\n    if (entering) {\n      y.jump(target.y + target.height);\n      scale.jump(target.scale);\n      height.jump(target.height);\n      contentOpacity.jump(target.content);\n      opacity.jump(0);\n    }\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  }, [\n    contentOpacity,\n    expanded,\n    height,\n    isPresent,\n    opacity,\n    reduce,\n    scale,\n    target.content,\n    target.height,\n    target.opacity,\n    target.scale,\n    target.y,\n    y,\n  ]);\n\n  // An update restarts the clock; pausing keeps whatever time is left.\n  useEffect(() => {\n    remaining.current = toast.duration;\n  }, [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 () => {\n      window.clearTimeout(timer);\n      remaining.current -= performance.now() - started;\n    };\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) {\n      void animate(opacity, 0, reducedFade).then(done);\n      return;\n    }\n    if (!swiped.current) {\n      animate(y, y.get() + 10, motionTokens.spring.smooth);\n      animate(scale, scale.get() * 0.96, motionTokens.spring.smooth);\n    }\n    void animate(opacity, 0, { duration: swiped.current ? 0.22 : motionTokens.duration.exit, ease: standard }).then(\n      done,\n    );\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 = {\n      pointerId: event.pointerId,\n      startX: event.clientX,\n      startY: event.clientY,\n      origin: x.get(),\n      active: false,\n      samples: [],\n    };\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,\n        dy = event.clientY - current.startY;\n      if (Math.hypot(dx, dy) < 6) return;\n      if (Math.abs(dy) > Math.abs(dx)) {\n        gesture.current = null;\n        return;\n      }\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],\n      last = current.samples[current.samples.length - 1];\n    // A finger that stopped before lifting carries no throw.\n    const velocity =\n      cancelled || !first || !last || last.t === first.t || event.timeStamp - last.t > 60\n        ? 0\n        : ((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 * 0.4) {\n      swiped.current = true;\n      if (!reduce)\n        animate(x, width + 40, { type: \"spring\", visualDuration: 0.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 * 0.3 : velocity });\n  }\n\n  function onCardClick(event: MouseEvent<HTMLDivElement>) {\n    if (suppressClick.current) {\n      suppressClick.current = false;\n      return;\n    }\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) => {\n    if (actionRef.current) actionRef.current.dataset.moving = moving ? \"true\" : \"false\";\n  };\n  const Icon = toast.type === \"loading\" ? null : icons[toast.type];\n  const swap = reduce ? fadeOnly : textSwap;\n\n  return (\n    <motion.li\n      ref={itemRef}\n      className=\"absolute right-0 bottom-0 left-0 origin-top\"\n      style={{ y, scale, height, opacity, zIndex: toast.seq }}\n      data-front={front}\n      data-expanded={expanded}\n      inert={hidden}\n      onKeyDown={onKeyDown}\n    >\n      <motion.div\n        ref={cardRef}\n        className={cn(\n          \"@container absolute inset-0 touch-pan-y overflow-hidden rounded-panel border border-line text-ink shadow-raised [-webkit-tap-highlight-color:transparent] transition-[background-color,border-color] duration-standard ease-standard data-dragging:cursor-grabbing data-dragging:select-none motion-reduce:transition-none\",\n          front || expanded ? \"bg-surface shadow-floating\" : \"bg-hover\",\n        )}\n        style={{ x, opacity: swipeFade }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={(event) => onPointerEnd(event, false)}\n        onPointerCancel={(event) => onPointerEnd(event, true)}\n        onClick={onCardClick}\n      >\n        <motion.div\n          ref={contentRef}\n          className=\"flex items-start gap-3 py-3 pr-3 pl-4 @max-[21rem]:flex-wrap @max-[21rem]:gap-y-2\"\n          style={{ opacity: contentOpacity }}\n        >\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\n            className={cn(\n              \"relative grid h-5 w-[18px] flex-none place-items-center transition-[color] duration-fast ease-standard motion-reduce:transition-none\",\n              typeColor[toast.type],\n            )}\n            data-type={toast.type}\n            aria-hidden=\"true\"\n          >\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <Swap\n                key={toast.type}\n                className=\"grid place-items-center [grid-area:1/1] [&_svg]:block\"\n                {...(reduce ? fadeOnly : iconSwap)}\n              >\n                {Icon ? (\n                  <Icon width={18} height={18} strokeWidth={1.75} />\n                ) : (\n                  <span className=\"block size-[15px] animate-toast-stack-spin rounded-full border-[1.75px] border-current border-r-transparent motion-reduce:animate-none\" />\n                )}\n              </Swap>\n            </AnimatePresence>\n          </span>\n          <div className=\"relative min-w-0 flex-1 text-(length:--text-sm) leading-5 tracking-body\">\n            <span className=\"absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\">\n              {typeLabels[toast.type]}:{\" \"}\n            </span>\n            <span className=\"relative block font-medium\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <Swap key={toast.title} className=\"block\" {...swap}>\n                  {toast.title}\n                </Swap>\n              </AnimatePresence>\n            </span>\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {toast.description ? (\n                <Swap key={toast.description} className=\"mt-0.5 block text-ink-2\" {...swap}>\n                  {toast.description}\n                </Swap>\n              ) : null}\n            </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 ? (\n              <motion.div\n                key=\"action\"\n                ref={actionRef}\n                className=\"-mt-1 flex flex-none justify-end data-[moving=true]:overflow-hidden @max-[21rem]:order-1 @max-[21rem]:mt-0 @max-[21rem]:basis-full @max-[21rem]:justify-start @max-[21rem]:pl-[calc(18px_+_var(--space-3))]\"\n                initial={reduce ? { opacity: 0 } : { width: 0, opacity: 0 }}\n                animate={{ width: \"auto\", opacity: 1 }}\n                exit={reduce ? { opacity: 0 } : { width: 0, opacity: 0 }}\n                transition={reduce ? reducedFade : { width: motionTokens.spring.smooth, opacity: fade }}\n                onAnimationStart={() => markMoving(true)}\n                onAnimationComplete={() => markMoving(false)}\n              >\n                <button\n                  type=\"button\"\n                  className={cn(\n                    pressable,\n                    \"h-7 rounded-button border-line bg-surface px-3 text-(length:--text-sm) font-medium whitespace-nowrap text-ink hover:border-line-strong hover:bg-hover\",\n                  )}\n                  onClick={runAction}\n                >\n                  {toast.action.label}\n                </button>\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n          <button\n            ref={closeRef}\n            type=\"button\"\n            className={cn(\n              pressable,\n              \"-mt-1 mr-[-2px] mb-0 ml-0 grid size-7 place-items-center rounded-button border-transparent bg-transparent text-ink-3 hover:bg-hover hover:text-ink\",\n            )}\n            aria-label=\"Dismiss notification\"\n            onClick={() => store.dismiss(id)}\n          >\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}\n\nexport function ToastStack({\n  label = \"Notifications\",\n  position = \"bottom-right\",\n  contained = false,\n  visibleToasts = 3,\n  hotkey = true,\n  className,\n}: ToastStackProps) {\n  const store = useStore();\n  const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot);\n  const reduce = !!useReducedMotion();\n  const pageHidden = useSyncExternalStore(\n    subscribeVisibility,\n    () => document.visibilityState === \"hidden\",\n    () => false,\n  );\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(\n    (id: string, height: number) =>\n      setHeights((current) => (current[id] === height ? current : { ...current, [id]: height })),\n    [],\n  );\n  const prune = useCallback(\n    () =>\n      setHeights((current) =>\n        Object.fromEntries(\n          store\n            .getSnapshot()\n            .filter((item) => item.id in current)\n            .map((item) => [item.id, current[item.id]!]),\n        ),\n      ),\n    [store],\n  );\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) => {\n      if (!regionRef.current?.contains(event.target as Node)) setTapped(false);\n    };\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))\n      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 (\n    // oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- focus tracking only: keyboard focus inside the region fans the stack out\n    <section\n      ref={regionRef}\n      className={cn(\n        \"pointer-events-none w-[min(22.5rem,calc(100%_-_var(--space-8)))]\",\n        contained\n          ? \"absolute bottom-4 z-2\"\n          : \"fixed bottom-6 z-60 max-[30rem]:right-0 max-[30rem]:bottom-4 max-[30rem]:left-0 max-[30rem]:mx-auto\",\n        viewportAt[contained ? \"contained\" : \"window\"][position],\n        className,\n      )}\n      aria-label={hotkey ? `${label} (Alt+T)` : label}\n      aria-live=\"polite\"\n      aria-relevant=\"additions text\"\n      aria-atomic=\"false\"\n      onFocus={onFocus}\n      onBlur={onBlur}\n    >\n      <ol\n        ref={listRef}\n        className=\"pointer-events-auto relative m-0 list-none p-0\"\n        style={{ height: listHeight }}\n        data-expanded={expanded}\n        onPointerEnter={(event) => {\n          if (event.pointerType === \"mouse\") setHovered(true);\n        }}\n        onPointerMove={(event) => {\n          if (event.pointerType === \"mouse\" && !hovered) setHovered(true);\n        }}\n        onPointerLeave={(event) => {\n          if (event.pointerType === \"mouse\") setHovered(false);\n        }}\n      >\n        <AnimatePresence onExitComplete={prune}>\n          {toasts.map((item, index) => (\n            <ToastItem\n              key={item.id}\n              toast={item}\n              target={targets[index]!}\n              expanded={expanded}\n              front={index === 0}\n              hidden={index >= visibleToasts}\n              paused={paused}\n              reduce={reduce}\n              store={store}\n              onMeasure={measure}\n              onDragChange={setDragging}\n              onTap={toggleTap}\n              onHandOff={handOff}\n            />\n          ))}\n        </AnimatePresence>\n      </ol>\n    </section>\n  );\n}\n\nexport default ToastStack;\n",
      "type": "registry:component",
      "target": "@components/iris/toast-stack/toast-stack.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/toast-stack",
  "meta": {
    "kind": "component",
    "group": "Feedback",
    "export": "ToastStack",
    "tags": [
      "toast",
      "notification",
      "sonner"
    ],
    "docs": "https://irisui.dev/components/toast-stack"
  }
}
