{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toast","type":"registry:component","title":"Toast","description":"Toasts that stay long enough to read based on their word count, and pause while hovered or while the tab is in the background.","author":"Ryan","dependencies":["clsx","lucide-react","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/toast.tsx","type":"registry:component","target":"@components/motion/toast.tsx","content":"\"use client\";\n// easeui.dev/components/motion/toast\n\nimport { CircleAlert, CircleCheck, X } from \"lucide-react\";\nimport {\n  type CSSProperties,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ToastTone = \"neutral\" | \"success\" | \"error\";\n\nexport interface ToastAction {\n  label: string;\n  onClick: () => void;\n}\n\nexport interface ToastOptions {\n  /** Second line with more detail. */\n  description?: string;\n  /** Adds a status icon. Default \"neutral\". */\n  tone?: ToastTone;\n  /** Time on screen in ms. By default it is worked out from the word count. */\n  duration?: number;\n  /** One button, such as Undo. Pressing it also closes the toast. */\n  action?: ToastAction;\n}\n\ntype ToastItem = {\n  id: number;\n  title: string;\n  description?: string;\n  tone: ToastTone;\n  duration: number;\n  action?: ToastAction;\n  closing: boolean;\n  swiped: boolean;\n};\n\n/** Cards visible in the stack. Older ones wait out of sight until there is room. */\nconst VISIBLE = 3;\n/** Older toasts are closed once this many are waiting. */\nconst MAX_QUEUED = 8;\n/** Space between cards when the stack is spread out. */\nconst GAP = 10;\n/** How far each card behind the front one peeks out above it. */\nconst PEEK = 12;\n/** How much smaller each card behind the front one is. */\nconst SCALE_STEP = 0.05;\nconst MOVE_MS = 400;\n/** Exits are quicker than entrances. */\nconst EXIT_MS = 200;\n/** Drag distance, in px, that dismisses a toast. */\nconst SWIPE_DISTANCE = 45;\n/** Drag speed, in px per ms, that dismisses a toast even when the drag is short. */\nconst SWIPE_VELOCITY = 0.11;\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n/** An average reading speed, in words per minute. */\nconst READING_WPM = 220;\n\nlet items: ToastItem[] = [];\nlet nextId = 0;\nconst listeners = new Set<() => void>();\nconst EMPTY: ToastItem[] = [];\n\nfunction emit() {\n  for (const listener of listeners) listener();\n}\n\nfunction subscribe(listener: () => void) {\n  listeners.add(listener);\n  return () => {\n    listeners.delete(listener);\n  };\n}\n\n/** Long enough to read the message once at a normal pace, plus a moment to notice it. */\nfunction readingTime(text: string) {\n  const words = text.trim().split(/\\s+/).filter(Boolean).length;\n  return Math.min(10_000, Math.max(3000, 1500 + (words / READING_WPM) * 60_000));\n}\n\nfunction dismiss(id: number, swiped = false) {\n  if (!items.some((item) => item.id === id && !item.closing)) return;\n  items = items.map((item) => (item.id === id ? { ...item, closing: true, swiped } : item));\n  emit();\n  setTimeout(() => {\n    items = items.filter((item) => item.id !== id);\n    emit();\n  }, EXIT_MS);\n}\n\nfunction show(title: string, options: ToastOptions = {}) {\n  nextId += 1;\n  const { description, tone = \"neutral\", action } = options;\n  const duration = options.duration ?? readingTime(`${title} ${description ?? \"\"}`);\n  items = [\n    ...items,\n    { id: nextId, title, description, tone, duration, action, closing: false, swiped: false },\n  ];\n  emit();\n  const open = items.filter((item) => !item.closing);\n  for (const item of open.slice(0, Math.max(0, open.length - MAX_QUEUED))) dismiss(item.id);\n  return nextId;\n}\n\n/** Shows a toast and returns its id. Render one Toaster somewhere in the app. */\nexport const toast = Object.assign(show, {\n  success: (title: string, options?: Omit<ToastOptions, \"tone\">) =>\n    show(title, { ...options, tone: \"success\" }),\n  error: (title: string, options?: Omit<ToastOptions, \"tone\">) =>\n    show(title, { ...options, tone: \"error\" }),\n  dismiss: (id: number) => dismiss(id),\n});\n\nfunction useMediaQuery(query: string) {\n  const [matches, setMatches] = useState(false);\n  useEffect(() => {\n    const list = window.matchMedia(query);\n    const update = () => setMatches(list.matches);\n    update();\n    list.addEventListener(\"change\", update);\n    return () => list.removeEventListener(\"change\", update);\n  }, [query]);\n  return matches;\n}\n\n/** True while the tab is in the background. */\nfunction useDocumentHidden() {\n  const [hidden, setHidden] = useState(false);\n  useEffect(() => {\n    const update = () => setHidden(document.hidden);\n    update();\n    document.addEventListener(\"visibilitychange\", update);\n    return () => document.removeEventListener(\"visibilitychange\", update);\n  }, []);\n  return hidden;\n}\n\nconst TONE_ICON: Record<ToastTone, ReactNode> = {\n  neutral: null,\n  success: <CircleCheck aria-hidden=\"true\" className=\"mt-px h-4 w-4 shrink-0 text-success\" />,\n  error: <CircleAlert aria-hidden=\"true\" className=\"mt-px h-4 w-4 shrink-0 text-destructive\" />,\n};\n\ntype Slot = {\n  /** 0 is the newest card, at the front of the stack. */\n  index: number;\n  /** Distance from the bottom when the stack is spread out, in px. */\n  offset: number;\n};\n\ntype CardProps = {\n  item: ToastItem;\n  slot: Slot | undefined;\n  expanded: boolean;\n  height: number | undefined;\n  frontHeight: number;\n  paused: boolean;\n  reduceMotion: boolean;\n  onHeight: (id: number, height: number) => void;\n};\n\nfunction ToastCard({\n  item,\n  slot,\n  expanded,\n  height,\n  frontHeight,\n  paused,\n  reduceMotion,\n  onHeight,\n}: CardProps) {\n  const contentRef = useRef<HTMLDivElement>(null);\n  const remaining = useRef(item.duration);\n  // A closing card has no slot any more, so it stays where it was while it leaves.\n  const lastSlot = useRef<Slot>({ index: 0, offset: 0 });\n  if (slot) lastSlot.current = slot;\n  const { index, offset } = lastSlot.current;\n\n  const [mounted, setMounted] = useState(false);\n  const [drag, setDrag] = useState<{ startY: number; startedAt: number; y: number } | null>(null);\n  const [swipeY, setSwipeY] = useState(0);\n\n  // Mount below the stack first, then move into place on the next frame so the entrance transitions.\n  useEffect(() => {\n    const frame = requestAnimationFrame(() => setMounted(true));\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  // Report the natural height, measured on the content so a clipped card still reports its full size.\n  useLayoutEffect(() => {\n    const content = contentRef.current;\n    if (!content) return;\n    const report = () => onHeight(item.id, content.offsetHeight);\n    report();\n    const observer = new ResizeObserver(report);\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, [item.id, onHeight]);\n\n  // The countdown only runs while nobody is reading or dragging. Pausing keeps the time that is left.\n  const dragging = drag !== null;\n  useEffect(() => {\n    if (paused || dragging || item.closing) return;\n    const startedAt = Date.now();\n    const timeout = setTimeout(() => dismiss(item.id), remaining.current);\n    return () => {\n      clearTimeout(timeout);\n      remaining.current -= Date.now() - startedAt;\n    };\n  }, [paused, dragging, item.closing, item.id]);\n\n  const onPointerDown = (event: PointerEvent<HTMLLIElement>) => {\n    if (item.closing || event.button !== 0) return;\n    if ((event.target as HTMLElement).closest(\"button\")) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setDrag({ startY: event.clientY, startedAt: Date.now(), y: 0 });\n  };\n\n  const onPointerMove = (event: PointerEvent<HTMLLIElement>) => {\n    if (!drag) return;\n    const raw = event.clientY - drag.startY;\n    // Down follows the finger. Up resists, since there is nowhere to go.\n    setDrag({ ...drag, y: raw > 0 ? raw : -Math.sqrt(-raw) });\n  };\n\n  const onPointerUp = () => {\n    if (!drag) return;\n    const velocity = drag.y / Math.max(1, Date.now() - drag.startedAt);\n    if (drag.y > SWIPE_DISTANCE || (drag.y > 8 && velocity > SWIPE_VELOCITY)) {\n      setSwipeY(drag.y);\n      dismiss(item.id, true);\n    }\n    setDrag(null);\n  };\n\n  const front = index === 0;\n  const hidden = index >= VISIBLE;\n  const lift = expanded ? offset : index * PEEK;\n  const scale = expanded ? 1 : 1 - index * SCALE_STEP;\n\n  let transform = `translateY(${-lift + (drag?.y ?? 0)}px) scale(${scale})`;\n  let opacity = hidden ? 0 : 1;\n  if (!mounted) {\n    transform = \"translateY(100%)\";\n    opacity = 0;\n  } else if (item.closing) {\n    transform = item.swiped\n      ? `translateY(calc(${-lift + swipeY}px + 100%))`\n      : `translateY(${-lift}px) translateY(${front ? \"50%\" : \"0px\"}) scale(${scale})`;\n    opacity = 0;\n  }\n\n  const duration = item.closing ? EXIT_MS : MOVE_MS;\n  const style: CSSProperties = {\n    transform,\n    opacity,\n    // Cards behind the front one take its height while stacked, so the stack stays tidy.\n    height: height === undefined ? undefined : expanded || front ? height : frontHeight,\n    zIndex: VISIBLE * 10 - index,\n    transition:\n      dragging || reduceMotion\n        ? \"none\"\n        : `transform ${duration}ms ${EASE_OUT}, opacity ${duration}ms ${EASE_OUT}, height ${MOVE_MS}ms ${EASE_OUT}`,\n  };\n\n  return (\n    <li\n      data-front={front}\n      aria-hidden={hidden || undefined}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onPointerCancel={() => setDrag(null)}\n      style={style}\n      className={cn(\n        \"absolute inset-x-0 bottom-0 origin-bottom touch-none select-none overflow-hidden rounded-2xl bg-background\",\n        \"shadow-[0_0_0_1px_var(--border-strong),0_10px_30px_-12px_rgb(0_0_0/0.3)]\",\n        (hidden || item.closing) && \"pointer-events-none\",\n      )}\n    >\n      <div\n        ref={contentRef}\n        className={cn(\n          \"flex items-start gap-3 py-3 pl-4 pr-2 transition-opacity duration-200 ease-out\",\n          !expanded && !front && \"opacity-0\",\n        )}\n      >\n        {TONE_ICON[item.tone]}\n        <div className=\"min-w-0 flex-1 text-sm\">\n          <p className=\"font-medium text-foreground\">{item.title}</p>\n          {item.description ? (\n            <p className=\"mt-0.5 text-pretty text-muted-foreground\">{item.description}</p>\n          ) : null}\n        </div>\n        {item.action ? (\n          <button\n            type=\"button\"\n            onClick={() => {\n              item.action?.onClick();\n              dismiss(item.id);\n            }}\n            className=\"inline-flex h-7 shrink-0 items-center rounded-full bg-foreground px-3 text-xs font-medium text-background transition-[background-color,scale] duration-150 ease-out hover:bg-foreground/85 active:scale-[0.97]\"\n          >\n            {item.action.label}\n          </button>\n        ) : null}\n        <button\n          type=\"button\"\n          aria-label=\"Dismiss\"\n          onClick={() => dismiss(item.id)}\n          className=\"relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 after:absolute after:-inset-1.5 hover:bg-muted hover:text-foreground\"\n        >\n          <X className=\"h-3.5 w-3.5\" />\n        </button>\n      </div>\n    </li>\n  );\n}\n\n/**\n * Where toasts appear. New toasts slide up from the bottom and push older ones\n * back into a stack. Hovering spreads the stack out and pauses every countdown,\n * and so does leaving the tab. Swipe a toast down to dismiss it.\n */\nexport function Toaster({ className }: { className?: string }) {\n  const toasts = useSyncExternalStore(subscribe, () => items, () => EMPTY);\n  const hiddenTab = useDocumentHidden();\n  const reduceMotion = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  const [expanded, setExpanded] = useState(false);\n  const [heights, setHeights] = useState<Record<number, number>>({});\n\n  const onHeight = useCallback((id: number, height: number) => {\n    setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }));\n  }, []);\n\n  const open = toasts.filter((item) => !item.closing).reverse();\n  const slots = new Map<number, Slot>();\n  let offset = 0;\n  open.forEach((item, index) => {\n    slots.set(item.id, { index, offset });\n    offset += (heights[item.id] ?? 0) + GAP;\n  });\n\n  const frontHeight = open[0] ? (heights[open[0].id] ?? 0) : 0;\n  const shown = open.slice(0, VISIBLE);\n  const spreadHeight =\n    shown.reduce((sum, item) => sum + (heights[item.id] ?? 0), 0) +\n    GAP * Math.max(0, shown.length - 1);\n\n  // Collapse once the last toast is gone, so the next one starts stacked.\n  useEffect(() => {\n    if (open.length === 0) setExpanded(false);\n  }, [open.length]);\n\n  return (\n    <section aria-label=\"Notifications\">\n      <ol\n        aria-live=\"polite\"\n        onPointerEnter={(event) => {\n          if (event.pointerType === \"mouse\") setExpanded(true);\n        }}\n        onPointerLeave={() => setExpanded(false)}\n        // A tap spreads the stack on touch screens, where there is no hover.\n        onPointerDown={(event) => {\n          if (event.pointerType !== \"mouse\") setExpanded(true);\n        }}\n        style={{ height: expanded ? spreadHeight : frontHeight }}\n        className={cn(\n          \"fixed bottom-4 right-4 z-[400] w-[min(22rem,calc(100vw-2rem))]\",\n          toasts.length === 0 && \"pointer-events-none\",\n          className,\n        )}\n      >\n        {toasts.map((item) => (\n          <ToastCard\n            key={item.id}\n            item={item}\n            slot={slots.get(item.id)}\n            expanded={expanded}\n            height={heights[item.id]}\n            frontHeight={frontHeight}\n            paused={expanded || hiddenTab}\n            reduceMotion={reduceMotion}\n            onHeight={onHeight}\n          />\n        ))}\n      </ol>\n    </section>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}