{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-stack",
  "type": "registry:ui",
  "title": "Toast stack",
  "description": "Layers incoming notices into a stack that expands and dismisses by swipe or keyboard.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "feedback"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/toast-stack/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Expandable notification queue with swipe dismissal",
    "sceneFamily": "product-mono",
    "motionRole": "lively",
    "primaryState": "Expanded notification queue",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/toast-stack.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/toast-stack.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type RefObject,\n  type ReactNode,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst SETTLE = { type: \"spring\", stiffness: 460, damping: 38, mass: 0.7 } as const;\nconst LEAVE = { duration: 0.16, ease: [0.23, 1, 0.32, 1] } as const;\nconst INSTANT = { duration: 0 } as const;\nconst TOAST_HEIGHT = 62;\nconst COLLAPSED_OFFSET = 12;\nconst EXPANDED_OFFSET = 64;\n\nexport type ToastTone = \"neutral\" | \"success\" | \"warning\" | \"error\";\n\nexport type ToastItem = {\n  id: string;\n  title: string;\n  description?: string;\n  tone?: ToastTone;\n  action?: ReactNode;\n};\n\nexport type ToastDismissReason = \"button\" | \"keyboard\" | \"swipe\";\n\nexport type ToastStackProps = {\n  items: readonly ToastItem[];\n  onDismiss: (id: string, reason: ToastDismissReason) => void;\n  label?: string;\n  dismissLabel?: (title: string) => string;\n  depthLabel?: (depth: number) => string;\n  maxVisible?: number;\n  returnFocusRef?: RefObject<HTMLElement | null>;\n  className?: string;\n};\n\nconst toneClass: Record<ToastTone, string> = {\n  neutral: \"bg-neutral-500 dark:bg-neutral-400\",\n  success: \"bg-[#55745D] dark:bg-[#87A88F]\",\n  warning: \"bg-[#A36F3F] dark:bg-[#D2A06F]\",\n  error: \"bg-[#93664F] dark:bg-[#C99078]\",\n};\n\nfunction useFinePointer() {\n  const [fine, setFine] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\") return;\n    const query = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setFine(query.matches);\n    update();\n    query.addEventListener(\"change\", update);\n    return () => query.removeEventListener(\"change\", update);\n  }, []);\n\n  return fine;\n}\n\nexport function ToastStack({\n  items,\n  onDismiss,\n  label = \"Notifications\",\n  dismissLabel = (title) => `Dismiss ${title}`,\n  depthLabel = (depth) => `Stack depth ${depth}`,\n  maxVisible = 4,\n  returnFocusRef,\n  className = \"\",\n}: ToastStackProps) {\n  const reduced = useReducedMotion() === true;\n  const finePointer = useFinePointer();\n  const [expanded, setExpanded] = useState(false);\n  const root = useRef<HTMLElement>(null);\n  const toastRefs = useRef(new Map<string, HTMLElement>());\n  const pendingDismiss = useRef<{ id: string; index: number; restoreFocus: boolean } | null>(null);\n  const previousIds = useRef<Set<string> | null>(null);\n  const [announcement, setAnnouncement] = useState<{ id: string; text: string } | null>(null);\n  const visible = useMemo(\n    () => items.slice(0, Math.max(1, maxVisible)),\n    [items, maxVisible],\n  );\n\n  useEffect(() => {\n    const first = items[0];\n    if (first && previousIds.current && !previousIds.current.has(first.id)) {\n      setAnnouncement({\n        id: first.id,\n        text: first.description ? `${first.title}. ${first.description}` : first.title,\n      });\n    } else if (!first) {\n      setAnnouncement(null);\n    }\n    previousIds.current = new Set(items.map((item) => item.id));\n  }, [items]);\n\n  const visibleOrder = visible.map((item) => item.id).join(\"\\u0000\");\n\n  useLayoutEffect(() => {\n    const pending = pendingDismiss.current;\n    if (!pending || items.some((item) => item.id === pending.id)) return;\n    pendingDismiss.current = null;\n    if (!pending.restoreFocus) return;\n\n    if (visible.length === 0) {\n      (returnFocusRef?.current ?? root.current)?.focus({ preventScroll: true });\n      return;\n    }\n    const nextIndex = Math.min(pending.index, visible.length - 1);\n    toastRefs.current.get(visible[nextIndex].id)?.focus({ preventScroll: true });\n  }, [items, returnFocusRef, visible, visibleOrder]);\n\n  const requestDismiss = (id: string, index: number, reason: ToastDismissReason) => {\n    const toast = toastRefs.current.get(id);\n    pendingDismiss.current = {\n      id,\n      index,\n      restoreFocus: Boolean(toast?.contains(document.activeElement)),\n    };\n    onDismiss(id, reason);\n  };\n\n  const dismissFromKeyboard = (event: KeyboardEvent<HTMLElement>, id: string, index: number) => {\n    const directDelete = event.target === event.currentTarget && (event.key === \"Delete\" || event.key === \"Backspace\");\n    const directEnter = event.target === event.currentTarget && event.key === \"Enter\";\n    if (event.key !== \"Escape\" && !directDelete && !directEnter) return;\n    event.preventDefault();\n    requestDismiss(id, index, \"keyboard\");\n  };\n\n  const collapsedHeight = visible.length === 0 ? 0 : TOAST_HEIGHT + (visible.length - 1) * COLLAPSED_OFFSET;\n  const expandedHeight = visible.length === 0 ? 0 : TOAST_HEIGHT + (visible.length - 1) * EXPANDED_OFFSET;\n\n  return (\n    <section\n      ref={root}\n      data-toast-stack\n      tabIndex={-1}\n      aria-label={label}\n      className={`w-full max-w-[390px] outline-none focus-visible:ring-2 focus-visible:ring-[#4568FF] focus-visible:ring-offset-2 ${className}`}\n      onFocusCapture={() => setExpanded(true)}\n      onBlurCapture={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget)) setExpanded(false);\n      }}\n      onPointerEnter={() => {\n        if (finePointer) setExpanded(true);\n      }}\n      onPointerLeave={(event) => {\n        if (finePointer && !event.currentTarget.contains(document.activeElement)) setExpanded(false);\n      }}\n    >\n      <motion.ol\n        layout={!reduced}\n        className=\"relative m-0 list-none p-0\"\n        initial={false}\n        style={{ height: expanded ? expandedHeight : collapsedHeight }}\n        transition={reduced ? INSTANT : SETTLE}\n      >\n        <AnimatePresence initial={false}>\n          {visible.map((item, index) => {\n            const depth = visible.length - index - 1;\n            const y = expanded ? index * EXPANDED_OFFSET : index * COLLAPSED_OFFSET;\n            const scale = expanded ? 1 : 1 - index * 0.025;\n            const tone = item.tone ?? \"neutral\";\n            return (\n              <motion.li\n                key={item.id}\n                layout={!reduced}\n                initial={\n                  reduced\n                    ? { opacity: 0 }\n                    : { opacity: 0, transform: \"translate3d(18px, 6px, 0) scale(0.97)\" }\n                }\n                animate={{\n                  opacity: 1,\n                  transform: `translate3d(${expanded ? 0 : index * 3}px, ${y}px, 0) scale(${scale})`,\n                }}\n                exit={\n                  reduced\n                    ? { opacity: 0, transition: INSTANT }\n                    : {\n                        opacity: 0,\n                        transform: \"translate3d(56px, 0, 0) scale(0.97)\",\n                        transition: LEAVE,\n                      }\n                }\n                transition={reduced ? INSTANT : SETTLE}\n                style={{ zIndex: visible.length - index, touchAction: \"pan-y\" }}\n                className=\"absolute inset-x-0 top-0\"\n              >\n                <motion.article\n                  tabIndex={0}\n                  ref={(node) => {\n                    if (node) toastRefs.current.set(item.id, node);\n                    else toastRefs.current.delete(item.id);\n                  }}\n                  aria-label={item.title}\n                  drag={reduced ? false : \"x\"}\n                  dragConstraints={{ left: 0, right: 0 }}\n                  dragElastic={0.2}\n                  onDragEnd={(_, info) => {\n                    if (Math.abs(info.offset.x) > 88 || Math.abs(info.velocity.x) > 520) {\n                      requestDismiss(item.id, index, \"swipe\");\n                    }\n                  }}\n                  onKeyDown={(event) => dismissFromKeyboard(event, item.id, index)}\n                  style={{ touchAction: \"pan-y\" }}\n                  className=\"flex min-h-[58px] items-center gap-3 rounded-[10px] border border-neutral-200 bg-white px-3 py-1.5 shadow-[0_1px_2px_rgba(0,0,0,.05)] outline-none focus-visible:border-[#4568FF] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:border-white/[0.16] dark:bg-[#181818] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className={`size-2.5 shrink-0 rounded-[4px] ${toneClass[tone]}`}\n                  />\n                  <span className=\"min-w-0 flex-1\">\n                    <strong className=\"block truncate text-[13px] font-medium text-neutral-800 dark:text-neutral-100\">\n                      {item.title}\n                    </strong>\n                    {item.description ? (\n                      <span className=\"mt-0.5 block truncate text-[11.5px] text-neutral-500 dark:text-neutral-400\">\n                        {item.description}\n                      </span>\n                    ) : null}\n                  </span>\n                  {item.action ? <span className=\"shrink-0\">{item.action}</span> : null}\n                  <button\n                    type=\"button\"\n                    aria-label={dismissLabel(item.title)}\n                    onKeyDown={(event) => {\n                      if (![\"Enter\", \"Escape\", \"Delete\", \"Backspace\"].includes(event.key)) return;\n                      event.preventDefault();\n                      event.stopPropagation();\n                      requestDismiss(item.id, index, \"keyboard\");\n                    }}\n                    onClick={() => requestDismiss(item.id, index, \"button\")}\n                    className=\"grid size-12 shrink-0 place-items-center rounded-[9px] text-neutral-500 outline-none transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-800 focus-visible:bg-neutral-100 focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:text-neutral-400 dark:hover:bg-white/10 dark:hover:text-neutral-100 dark:focus-visible:bg-white/10 dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n                  >\n                    <svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\" aria-hidden=\"true\">\n                      <path d=\"m4 4 8 8M12 4l-8 8\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n                    </svg>\n                  </button>\n                </motion.article>\n                <span className=\"sr-only\">{depthLabel(depth + 1)}</span>\n              </motion.li>\n            );\n          })}\n        </AnimatePresence>\n      </motion.ol>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement ? <span key={announcement.id}>{announcement.text}</span> : null}\n      </span>\n    </section>\n  );\n}\n"
    }
  ]
}
