{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-stack",
  "type": "registry:ui",
  "title": "Toast Stack",
  "description": "Stacked notifications that peek behind each other, fan out on hover and swipe away.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/toast-stack.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type PanInfo } from \"motion/react\";\n\n/* ---------------------------------- types ---------------------------------- */\n\nexport type ToastType = \"default\" | \"success\" | \"error\" | \"info\" | \"loading\";\nexport type ToastPosition = \"top-left\" | \"top-center\" | \"top-right\" | \"bottom-left\" | \"bottom-center\" | \"bottom-right\";\n\nexport interface ToastAction {\n  label: string;\n  onClick: () => void;\n}\n\nexport interface ToastOptions {\n  /** Reuse an id to replace an existing toast. */\n  id?: string;\n  title: string;\n  description?: string;\n  type?: ToastType;\n  /** Auto-dismiss delay in ms. Infinity keeps the toast until dismissed. Defaults to the stack's duration. */\n  duration?: number;\n  /** Optional action button. The toast closes after the action runs. */\n  action?: ToastAction;\n}\n\nexport interface ToastRecord {\n  id: string;\n  title: string;\n  description?: string;\n  type: ToastType;\n  duration?: number;\n  action?: ToastAction;\n  /** Increments on every update, restarting the auto-dismiss timer. */\n  version: number;\n}\n\nexport interface ToastPromiseMessages<T> {\n  loading: string;\n  success: string | ((value: T) => string);\n  error: string | ((error: unknown) => string);\n}\n\nexport interface ToastController {\n  /** Toasts, newest first. */\n  toasts: ToastRecord[];\n  /** Latest message for the live regions (used by ToastStack). */\n  announcement: { text: string; assertive: boolean; key: number };\n  /** Shows a toast and returns its id. */\n  toast: (options: ToastOptions) => string;\n  /** Updates an existing toast (e.g. loading → success) and restarts its timer. */\n  update: (id: string, options: Partial<Omit<ToastOptions, \"id\">>) => void;\n  /** Dismisses one toast, or all of them when called without an id. */\n  dismiss: (id?: string) => void;\n  /** Shows a loading toast that resolves to success or error with the promise. Returns the same promise. */\n  promise: <T>(promise: Promise<T>, messages: ToastPromiseMessages<T>) => Promise<T>;\n}\n\nlet counter = 0;\nconst nextId = () => `toast-${++counter}`;\n\nconst toRecord = (options: ToastOptions): ToastRecord => ({\n  id: options.id ?? nextId(),\n  title: options.title,\n  description: options.description,\n  type: options.type ?? \"default\",\n  duration: options.duration,\n  action: options.action,\n  version: 0,\n});\n\n/**\n * Creates a toast controller scoped to the calling component. Pass it to <ToastStack toaster={...} />\n * and call `toast()`, `update()`, `promise()` or `dismiss()` from event handlers.\n */\nexport function useToastStack(initial?: ToastOptions[]): ToastController {\n  const [toasts, setToasts] = React.useState<ToastRecord[]>(() => (initial ?? []).map(toRecord));\n  const [announcement, setAnnouncement] = React.useState({ text: \"\", assertive: false, key: 0 });\n\n  const announce = (text: string, assertive: boolean) => setAnnouncement((prev) => ({ text, assertive, key: prev.key + 1 }));\n\n  const update: ToastController[\"update\"] = (id, options) => {\n    setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, ...options, version: t.version + 1 } : t)));\n    if (options.title) announce(options.title, options.type === \"error\");\n  };\n\n  const toast: ToastController[\"toast\"] = (options) => {\n    const record = toRecord(options);\n    setToasts((prev) =>\n      prev.some((t) => t.id === record.id)\n        ? prev.map((t) => (t.id === record.id ? { ...record, version: t.version + 1 } : t))\n        : [record, ...prev],\n    );\n    announce(options.description ? `${options.title}. ${options.description}` : options.title, record.type === \"error\");\n    return record.id;\n  };\n\n  const dismiss: ToastController[\"dismiss\"] = (id) => {\n    setToasts((prev) => (id === undefined ? [] : prev.filter((t) => t.id !== id)));\n  };\n\n  function promise<T>(source: Promise<T>, messages: ToastPromiseMessages<T>): Promise<T> {\n    const id = toast({ type: \"loading\", title: messages.loading, duration: Infinity });\n    source.then(\n      (value) =>\n        update(id, {\n          type: \"success\",\n          title: typeof messages.success === \"function\" ? messages.success(value) : messages.success,\n          duration: undefined,\n        }),\n      (error: unknown) =>\n        update(id, {\n          type: \"error\",\n          title: typeof messages.error === \"function\" ? messages.error(error) : messages.error,\n          duration: undefined,\n        }),\n    );\n    return source;\n  }\n\n  return { toasts, announcement, toast, update, dismiss, promise };\n}\n\n/* ---------------------------------- icons ---------------------------------- */\n\nfunction TypeIcon({ type }: { type: ToastType }) {\n  const base = \"mt-px h-[18px] w-[18px] shrink-0\";\n  switch (type) {\n    case \"success\":\n      return (\n        <svg viewBox=\"0 0 20 20\" className={`${base} text-emerald-600 dark:text-[#B6F3D1]`} aria-hidden=\"true\">\n          <circle cx=\"10\" cy=\"10\" r=\"8\" fill=\"currentColor\" fillOpacity=\"0.14\" />\n          <path d=\"m6.5 10.2 2.3 2.3 4.7-4.9\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        </svg>\n      );\n    case \"error\":\n      return (\n        <svg viewBox=\"0 0 20 20\" className={`${base} text-red-600 dark:text-red-400`} aria-hidden=\"true\">\n          <circle cx=\"10\" cy=\"10\" r=\"8\" fill=\"currentColor\" fillOpacity=\"0.14\" />\n          <path d=\"M10 6v4.8M10 13.6v.1\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />\n        </svg>\n      );\n    case \"info\":\n      return (\n        <svg viewBox=\"0 0 20 20\" className={`${base} text-[var(--ts-accent)]`} aria-hidden=\"true\">\n          <circle cx=\"10\" cy=\"10\" r=\"8\" fill=\"currentColor\" fillOpacity=\"0.16\" />\n          <path d=\"M10 9.2V14M10 6.3v.1\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" />\n        </svg>\n      );\n    case \"loading\":\n      return (\n        <svg viewBox=\"0 0 20 20\" className={`${base} animate-spin text-zinc-500 motion-reduce:animate-none dark:text-zinc-400`} aria-hidden=\"true\">\n          <circle cx=\"10\" cy=\"10\" r=\"7\" fill=\"none\" stroke=\"currentColor\" strokeOpacity=\"0.2\" strokeWidth=\"2\" />\n          <path d=\"M17 10a7 7 0 0 0-7-7\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n        </svg>\n      );\n    default:\n      return null;\n  }\n}\n\n/* ---------------------------------- toast ---------------------------------- */\n\ninterface ToastItemProps {\n  toast: ToastRecord;\n  index: number;\n  count: number;\n  expanded: boolean;\n  paused: boolean;\n  /** Offset from the stack edge in px (positive = away from the edge). */\n  offset: number;\n  /** Height applied while collapsed behind the front toast; undefined = natural height. */\n  clampHeight: number | undefined;\n  hiddenBehind: boolean;\n  isBottom: boolean;\n  duration: number;\n  swipeToDismiss: boolean;\n  showProgress: boolean;\n  closeLabel: string;\n  spring: { type: \"spring\"; stiffness: number; damping: number; mass: number } | { duration: number };\n  reduceMotion: boolean;\n  onHeight: (id: string, height: number) => void;\n  onDismiss: (id: string, viaKeyboard: boolean) => void;\n}\n\nfunction ToastItem({\n  toast,\n  index,\n  count,\n  expanded,\n  paused,\n  offset,\n  clampHeight,\n  hiddenBehind,\n  isBottom,\n  duration,\n  swipeToDismiss,\n  showProgress,\n  closeLabel,\n  spring,\n  reduceMotion,\n  onHeight,\n  onDismiss,\n}: ToastItemProps) {\n  const uid = React.useId();\n  const x = useMotionValue(0);\n  const contentOpacity = useTransform(x, [-180, 0, 180], [0, 1, 0]);\n  const barRef = React.useRef<HTMLSpanElement>(null);\n  const animationRef = React.useRef<Animation | null>(null);\n  const total = toast.duration ?? duration;\n  const timed = toast.type !== \"loading\" && Number.isFinite(total) && total > 0;\n  const remainingRef = React.useRef(total);\n\n  const dismissFromTimer = React.useEffectEvent(() => onDismiss(toast.id, false));\n\n  // A new version (update) or duration restarts the countdown.\n  React.useEffect(() => {\n    remainingRef.current = total;\n  }, [toast.version, total]);\n\n  // Auto-dismiss that pauses (keeping the remaining time) while hovered, focused or the tab is hidden.\n  React.useEffect(() => {\n    if (!timed || paused) return;\n    const started = performance.now();\n    const timer = window.setTimeout(() => dismissFromTimer(), Math.max(0, remainingRef.current));\n    return () => {\n      window.clearTimeout(timer);\n      remainingRef.current -= performance.now() - started;\n    };\n  }, [timed, paused, toast.version, total]);\n\n  // Progress hairline mirrors the countdown with the Web Animations API.\n  React.useEffect(() => {\n    const bar = barRef.current;\n    if (!bar || !timed) return;\n    const animation = bar.animate([{ transform: \"scaleX(1)\" }, { transform: \"scaleX(0)\" }], {\n      duration: total,\n      easing: \"linear\",\n      fill: \"forwards\",\n    });\n    animationRef.current = animation;\n    return () => {\n      animation.cancel();\n      animationRef.current = null;\n    };\n  }, [timed, toast.version, total]);\n\n  React.useEffect(() => {\n    const animation = animationRef.current;\n    if (!animation) return;\n    if (paused) animation.pause();\n    else animation.play();\n  }, [paused, toast.version, timed]);\n\n  const measure = (node: HTMLDivElement | null) => {\n    if (!node) return;\n    const observer = new ResizeObserver(() => onHeight(toast.id, node.offsetHeight));\n    observer.observe(node);\n    return () => observer.disconnect();\n  };\n\n  const handleDragEnd = (_: PointerEvent, info: PanInfo) => {\n    if (Math.abs(info.offset.x) > 80 || Math.abs(info.velocity.x) > 600) {\n      const direction = Math.sign(info.offset.x || info.velocity.x) || 1;\n      animate(x, direction * 420, reduceMotion ? { duration: 0 } : { duration: 0.2, ease: \"easeOut\" }).then(() =>\n        onDismiss(toast.id, false),\n      );\n    } else {\n      animate(x, 0, reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 34 });\n    }\n  };\n\n  const isFront = index === 0;\n  const scale = expanded || reduceMotion ? 1 : 1 - Math.min(index, 3) * 0.05;\n  const enterY = isBottom ? 16 : -16;\n\n  return (\n    <motion.li\n      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: enterY, scale: 0.96 }}\n      animate={{\n        opacity: hiddenBehind ? 0 : 1,\n        y: isBottom ? -offset : offset,\n        scale,\n        height: clampHeight ?? \"auto\",\n      }}\n      exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.94, transition: { duration: 0.18 } }}\n      transition={spring}\n      drag={swipeToDismiss && (expanded || isFront) ? \"x\" : false}\n      dragDirectionLock\n      dragMomentum={false}\n      onDragEnd={handleDragEnd}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\") {\n          event.stopPropagation();\n          onDismiss(toast.id, true);\n        }\n      }}\n      // Toasts faded out behind the stack are inert until it expands.\n      inert={hiddenBehind}\n      aria-labelledby={`${uid}-title`}\n      aria-describedby={toast.description ? `${uid}-description` : undefined}\n      className={`absolute inset-x-0 overflow-hidden rounded-xl border border-zinc-900/10 bg-white text-zinc-900 shadow-[0_10px_30px_-14px_rgba(0,0,0,0.3),0_1px_2px_rgba(0,0,0,0.04)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-[0_10px_30px_-14px_rgba(0,0,0,0.85)] ${\n        isBottom ? \"bottom-0 origin-top\" : \"top-0 origin-bottom\"\n      } ${hiddenBehind ? \"pointer-events-none\" : \"\"} ${swipeToDismiss ? \"touch-pan-y\" : \"\"}`}\n      style={{ x, zIndex: count - index }}\n    >\n      <motion.div\n        ref={measure}\n        // Content of toasts peeking behind the front one is hidden; the card itself stays visible.\n        animate={{ opacity: expanded || isFront ? 1 : 0 }}\n        transition={reduceMotion ? { duration: 0 } : { duration: 0.2 }}\n      >\n        <motion.div style={{ opacity: contentOpacity }} className=\"flex items-start gap-3 py-3.5 pl-3.5 pr-10\">\n          <TypeIcon type={toast.type} />\n          <div className=\"min-w-0 flex-1\">\n            <p id={`${uid}-title`} className=\"text-[13.5px] font-medium leading-snug tracking-tight\">\n              {toast.title}\n            </p>\n            {toast.description && (\n              <p id={`${uid}-description`} className=\"mt-0.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400\">\n                {toast.description}\n              </p>\n            )}\n          </div>\n          {toast.action && (\n            <button\n              type=\"button\"\n              onClick={() => {\n                toast.action?.onClick();\n                onDismiss(toast.id, false);\n              }}\n              className=\"shrink-0 self-center rounded-md bg-[var(--ts-accent)] px-2.5 py-1 text-[12px] font-medium text-zinc-950 transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ts-accent)]\"\n            >\n              {toast.action.label}\n            </button>\n          )}\n        </motion.div>\n      </motion.div>\n      <button\n        type=\"button\"\n        aria-label={`${closeLabel}: ${toast.title}`}\n        onClick={(event) => onDismiss(toast.id, event.detail === 0)}\n        className=\"absolute right-2 top-2.5 flex h-6 w-6 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[var(--ts-accent)] dark:text-zinc-500 dark:hover:bg-white/[0.08] dark:hover:text-white\"\n      >\n        <svg viewBox=\"0 0 16 16\" className=\"h-3.5 w-3.5\" aria-hidden=\"true\">\n          <path d=\"m4.5 4.5 7 7M11.5 4.5l-7 7\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n        </svg>\n      </button>\n      {showProgress && timed && (\n        <span\n          ref={barRef}\n          aria-hidden=\"true\"\n          className={`absolute inset-x-3.5 bottom-0 h-px origin-left bg-[var(--ts-accent)] ${expanded || isFront ? \"opacity-70\" : \"opacity-0\"}`}\n        />\n      )}\n    </motion.li>\n  );\n}\n\n/* ---------------------------------- stack ---------------------------------- */\n\nexport interface ToastStackProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** Controller returned by useToastStack(). */\n  toaster: ToastController;\n  /** Corner or edge the stack is anchored to. */\n  position?: ToastPosition;\n  /** Default auto-dismiss delay in ms. */\n  duration?: number;\n  /** Toasts visible in the collapsed stack (older ones fade out behind). */\n  visibleToasts?: number;\n  /** Always show the stack expanded as a list. */\n  expand?: boolean;\n  /** Allow dismissing toasts with a horizontal swipe. */\n  swipeToDismiss?: boolean;\n  /** Show a hairline counting down each toast's timer. */\n  showProgress?: boolean;\n  /** \"fixed\" anchors to the viewport; \"absolute\" anchors to the nearest positioned ancestor. */\n  strategy?: \"fixed\" | \"absolute\";\n  /** Distance from the anchored edges in px. */\n  offset?: number;\n  /** Gap between expanded toasts in px. */\n  gap?: number;\n  /** Width of the stack in px (shrinks on narrow screens). */\n  width?: number;\n  /** Accent for info icons, actions, progress and focus rings. */\n  accent?: string;\n  /** Accessible name of the notifications region. */\n  label?: string;\n  /** Accessible name prefix of each dismiss button. */\n  closeLabel?: string;\n}\n\nconst PEEK = 14;\n\nfunction useDocumentHidden() {\n  return React.useSyncExternalStore(\n    (onChange) => {\n      document.addEventListener(\"visibilitychange\", onChange);\n      return () => document.removeEventListener(\"visibilitychange\", onChange);\n    },\n    () => document.hidden,\n    () => false,\n  );\n}\n\n/**\n * A stack of notifications: the newest sits in front with older ones peeking behind; hover or focus\n * fans the stack out into a list. Timers pause while the stack is hovered, focused or the tab is hidden.\n */\nexport function ToastStack({\n  toaster,\n  position = \"bottom-right\",\n  duration = 4000,\n  visibleToasts = 3,\n  expand = false,\n  swipeToDismiss = true,\n  showProgress = true,\n  strategy = \"fixed\",\n  offset = 20,\n  gap = 10,\n  width = 356,\n  accent = \"#FF7448\",\n  label = \"Notifications\",\n  closeLabel = \"Dismiss notification\",\n  className = \"\",\n  style,\n  ...props\n}: ToastStackProps) {\n  const { toasts, announcement, dismiss } = toaster;\n  const reduceMotion = useReducedMotion() ?? false;\n  const documentHidden = useDocumentHidden();\n  const regionRef = React.useRef<HTMLElement>(null);\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const [touchOpen, setTouchOpen] = React.useState(false);\n  const [heights, setHeights] = React.useState<Record<string, number>>({});\n\n  const isBottom = position.startsWith(\"bottom\");\n  const expanded = expand || hovered || focused || touchOpen;\n  const paused = hovered || focused || touchOpen || documentHidden;\n\n  // Touch has no hover: a tap fans the stack out and a tap elsewhere folds it back.\n  React.useEffect(() => {\n    if (!touchOpen) return;\n    const onDown = (event: PointerEvent) => {\n      if (!regionRef.current?.contains(event.target as Node)) setTouchOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [touchOpen]);\n\n  const onHeight = (id: string, height: number) =>\n    setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }));\n\n  const onDismiss = (id: string, viaKeyboard: boolean) => {\n    // Keep keyboard users inside the region instead of dropping focus on <body>.\n    if (viaKeyboard) regionRef.current?.focus({ preventScroll: true });\n    dismiss(id);\n  };\n\n  const heightOf = (id: string) => heights[id] ?? 64;\n  const frontHeight = toasts.length ? heightOf(toasts[0].id) : 0;\n  const shown = Math.min(toasts.length, visibleToasts);\n\n  // Offsets from the anchored edge for every toast.\n  const offsets: number[] = [];\n  let stacked = 0;\n  for (let i = 0; i < toasts.length; i++) {\n    offsets.push(expanded ? stacked : Math.min(i, visibleToasts - 1) * PEEK);\n    stacked += heightOf(toasts[i].id) + gap;\n  }\n  const listHeight = !toasts.length ? 0 : expanded ? stacked - gap : frontHeight + (shown - 1) * PEEK;\n\n  const spring = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, stiffness: 420, damping: 36, mass: 0.9 };\n\n  const [vertical, horizontal] = position.split(\"-\") as [\"top\" | \"bottom\", \"left\" | \"center\" | \"right\"];\n  const placement: React.CSSProperties = {\n    [vertical]: offset,\n    ...(horizontal === \"left\" ? { left: offset } : horizontal === \"right\" ? { right: offset } : { left: \"50%\", translate: \"-50% 0\" }),\n    width: `min(${width}px, calc(100% - ${offset * 2}px))`,\n  };\n\n  return (\n    <section\n      ref={regionRef}\n      aria-label={label}\n      tabIndex={-1}\n      className={`${strategy === \"fixed\" ? \"fixed\" : \"absolute\"} z-[100] outline-none ${className}`}\n      style={{ [\"--ts-accent\" as string]: accent, ...placement, ...style }}\n      onFocus={() => setFocused(true)}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);\n      }}\n      {...props}\n    >\n      {/* Persistent live regions: errors interrupt, everything else waits its turn. */}\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {!announcement.assertive && <span key={announcement.key}>{announcement.text}</span>}\n      </div>\n      <div role=\"alert\" aria-live=\"assertive\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement.assertive && <span key={announcement.key}>{announcement.text}</span>}\n      </div>\n\n      <motion.ol\n        initial={false}\n        animate={{ height: listHeight }}\n        transition={spring}\n        className=\"relative\"\n        onPointerEnter={(event) => {\n          if (event.pointerType === \"mouse\") setHovered(true);\n        }}\n        onPointerLeave={(event) => {\n          if (event.pointerType === \"mouse\") setHovered(false);\n        }}\n        onPointerDown={(event) => {\n          if (event.pointerType !== \"mouse\") setTouchOpen(true);\n        }}\n      >\n        <AnimatePresence initial={false}>\n          {toasts.map((toast, i) => (\n            <ToastItem\n              key={toast.id}\n              toast={toast}\n              index={i}\n              count={toasts.length}\n              expanded={expanded}\n              paused={paused}\n              offset={offsets[i]}\n              clampHeight={!expanded && i > 0 ? frontHeight : undefined}\n              hiddenBehind={!expanded && i >= visibleToasts}\n              isBottom={isBottom}\n              duration={duration}\n              swipeToDismiss={swipeToDismiss}\n              showProgress={showProgress}\n              closeLabel={closeLabel}\n              spring={spring}\n              reduceMotion={reduceMotion}\n              onHeight={onHeight}\n              onDismiss={onDismiss}\n            />\n          ))}\n        </AnimatePresence>\n      </motion.ol>\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/toast-stack"
}
