"use client"; import { AlertCircle, Bell, Check, Info, LoaderCircle, X, type LucideIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion, type Transition, } from "motion/react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; export type ToastStatus = "neutral" | "info" | "loading" | "success" | "error"; export type ToastPosition = | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; export type AnimatedToastAction = { label: ReactNode; onClick: (toast: AnimatedToast) => void; }; export type AnimatedToast = { id: string; title: ReactNode; description?: ReactNode; status?: ToastStatus; icon?: ReactNode; action?: AnimatedToastAction; duration?: number; dismissible?: boolean; createdAt?: number; }; export type ToastInput = Omit & { id?: string; }; export type ToastClassNames = { root?: string; item?: string; surface?: string; iconWrap?: string; content?: string; title?: string; description?: string; action?: string; close?: string; progress?: string; }; export interface AnimatedToastStackProps { toasts: AnimatedToast[]; onDismiss?: (id: string) => void; position?: ToastPosition; placement?: "static" | "fixed" | "absolute"; fixed?: boolean; portal?: boolean; portalRoot?: Element | null; maxVisible?: number; className?: string; classNames?: ToastClassNames; icons?: Partial>; renderToast?: (toast: AnimatedToast) => ReactNode; } export interface UseAnimatedToastStackOptions { initialToasts?: ToastInput[]; defaultDuration?: number; limit?: number; } const STACK_SPRING: Transition = { type: "spring", stiffness: 420, damping: 34, mass: 0.75, }; const CONTENT_TRANSITION = { duration: 0.28, ease: [0.16, 1, 0.3, 1], } as const; const STATUS_ICON: Record = { neutral: Bell, info: Info, loading: LoaderCircle, success: Check, error: AlertCircle, }; const STATUS_CLASS: Record = { neutral: "text-(--color-fg-muted) bg-(--color-fg)/[0.05]", info: "text-(--color-accent) bg-(--color-accent)/10", loading: "text-(--color-violet) bg-(--color-violet)/10", success: "text-(--color-success) bg-(--color-success)/10", error: "text-(--color-danger) bg-(--color-danger)/10", }; const POSITION_CLASS: Record = { "top-left": "left-4 top-4", "top-center": "left-1/2 top-4 -translate-x-1/2", "top-right": "right-4 top-4", "bottom-left": "bottom-6 left-4", "bottom-center": "bottom-6 left-1/2 -translate-x-1/2", "bottom-right": "bottom-6 right-4", }; let idSeed = 0; function createToast(input: ToastInput, defaultDuration: number): AnimatedToast { return { duration: defaultDuration, dismissible: true, ...input, id: input.id ?? `toast-${Date.now()}-${idSeed++}`, createdAt: Date.now(), }; } export function useAnimatedToastStack({ initialToasts = [], defaultDuration = 4200, limit, }: UseAnimatedToastStackOptions = {}) { const toastTimers = useRef>(new Map()); const [toasts, setToasts] = useState(() => initialToasts.map((toast) => createToast(toast, defaultDuration)), ); const dismissToast = useCallback((id: string) => { setToasts((current) => current.filter((toast) => toast.id !== id)); }, []); const clearToasts = useCallback(() => { setToasts([]); }, []); const showToast = useCallback( (input: ToastInput) => { const toast = createToast(input, defaultDuration); setToasts((current) => { const next = [...current, toast]; return typeof limit === "number" ? next.slice(-limit) : next; }); return toast.id; }, [defaultDuration, limit], ); const updateToast = useCallback((id: string, patch: Partial) => { setToasts((current) => current.map((toast) => toast.id === id ? { ...toast, ...patch, id, createdAt: patch.duration === undefined ? toast.createdAt : Date.now(), } : toast, ), ); }, []); useEffect(() => { const activeIds = new Set(toasts.map((toast) => toast.id)); toastTimers.current.forEach((entry, id) => { if (!activeIds.has(id)) { window.clearTimeout(entry.timer); toastTimers.current.delete(id); } }); toasts.forEach((toast) => { const duration = toast.duration ?? defaultDuration; const existing = toastTimers.current.get(toast.id); if (duration <= 0) { if (existing) { window.clearTimeout(existing.timer); toastTimers.current.delete(toast.id); } return; } const createdAt = toast.createdAt ?? Date.now(); const signature = `${createdAt}:${duration}`; if (existing?.signature === signature) { return; } if (existing) { window.clearTimeout(existing.timer); } const elapsed = Date.now() - createdAt; const remaining = Math.max(duration - elapsed, 0); const timer = window.setTimeout(() => { toastTimers.current.delete(toast.id); dismissToast(toast.id); }, remaining); toastTimers.current.set(toast.id, { timer, signature }); }); }, [defaultDuration, dismissToast, toasts]); useEffect(() => { const timers = toastTimers.current; return () => { timers.forEach((entry) => window.clearTimeout(entry.timer)); timers.clear(); }; }, []); return useMemo( () => ({ toasts, showToast, updateToast, dismissToast, clearToasts, setToasts, }), [clearToasts, dismissToast, showToast, toasts, updateToast], ); } export function AnimatedToastStack({ toasts, onDismiss, position = "bottom-right", placement, fixed = false, portal, portalRoot, maxVisible = 4, className, classNames, icons, renderToast, }: AnimatedToastStackProps) { const [mounted, setMounted] = useState(false); const visibleToasts = toasts.slice(-maxVisible); const isBottom = position.startsWith("bottom"); const resolvedPlacement = placement ?? (fixed ? "fixed" : "static"); const shouldPortal = portal ?? resolvedPlacement === "fixed"; useEffect(() => { setMounted(true); }, []); const stack = (
    {visibleToasts.map((toast, index) => ( ))}
); if (shouldPortal && !mounted) { return null; } if (shouldPortal) { return createPortal(stack, portalRoot ?? document.body); } return stack; } const ToastItem = memo(function ToastItem({ toast, index, onDismiss, classNames, icons, renderToast, }: { toast: AnimatedToast; index: number; onDismiss?: (id: string) => void; classNames?: ToastClassNames; icons?: Partial>; renderToast?: (toast: AnimatedToast) => ReactNode; }) { const reduce = useReducedMotion(); const status = toast.status ?? "neutral"; const Icon = STATUS_ICON[status]; const iconNode = icons?.[status] ?? toast.icon ?? ; const canDismiss = toast.dismissible !== false && Boolean(onDismiss); return ( { if (!canDismiss || !onDismiss) return; if (Math.abs(info.offset.x) > 72 || Math.abs(info.velocity.x) > 520) { onDismiss(toast.id); } }} className={cn("pointer-events-auto relative will-change-transform", classNames?.item)} style={{ zIndex: 20 - index }} >
{renderToast ? ( renderToast(toast) ) : (
{status === "loading" ? ( {iconNode} ) : ( iconNode )}

{toast.title}

{toast.description ? (

{toast.description}

) : null}
{toast.action ? ( ) : null}
{canDismiss ? ( ) : null}
)}
); });