"use client"; import { AlertCircle, Bell, Check, Info, LoaderCircle, X, type LucideIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion, type Transition, } from "motion/react"; import { isValidElement, memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT } from "@/lib/ease"; 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; }; /** Copy for one phase of a promise toast: a plain title, or a partial toast. */ export type ToastPromiseContent = | ReactNode | (Omit & { title: ReactNode }); export type ToastPromiseMessages = { loading: ToastPromiseContent; success: ToastPromiseContent | ((data: T) => ToastPromiseContent); error: ToastPromiseContent | ((error: unknown) => ToastPromiseContent); /** Auto-dismiss delay once the promise settles. Defaults to the hook's defaultDuration. */ duration?: number; /** Reuse an existing toast id instead of opening a new one. */ id?: string; }; export type ToastPromiseResult = { id: string; /** The original promise, so callers can still `await` the work. */ result: Promise; }; 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: EASE_OUT, } as const; const STATUS_ICON: Record = { neutral: Bell, info: Info, loading: LoaderCircle, success: Check, error: AlertCircle, }; const STATUS_CLASS: Record = { neutral: "text-muted-foreground bg-primary/[0.05]", info: "text-primary bg-primary/10", loading: "text-primary bg-primary/10", success: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", error: "text-destructive bg-destructive/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(), }; } type ToastPromiseObject = Omit & { title: ReactNode }; function isToastContentObject(content: ToastPromiseContent): content is ToastPromiseObject { return ( typeof content === "object" && content !== null && !isValidElement(content) && !Array.isArray(content) && "title" in content ); } function resolvePromiseContent(content: ToastPromiseContent): ToastPromiseObject { return isToastContentObject(content) ? content : { title: content as ReactNode }; } 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, ), ); }, []); /** * One toast that tracks a promise: a spinner while pending (no auto-dismiss), * then morphs in place into success or error. Settling restarts the dismiss * timer because `updateToast` re-stamps `createdAt` whenever a duration is set. * Returns the toast id plus the original promise so callers can still await it. */ const promiseToast = useCallback( ( promise: Promise | (() => Promise), messages: ToastPromiseMessages, ): ToastPromiseResult => { const settledDuration = messages.duration ?? defaultDuration; const loading: ToastInput = { ...resolvePromiseContent(messages.loading), status: "loading", duration: 0, }; let id: string; const reuseId = messages.id; if (reuseId) { id = reuseId; setToasts((current) => { if (current.some((toast) => toast.id === reuseId)) { return current.map((toast) => toast.id === reuseId ? { ...toast, description: undefined, action: undefined, icon: undefined, ...loading, id: reuseId, } : toast, ); } const next = [...current, createToast({ ...loading, id: reuseId }, defaultDuration)]; return typeof limit === "number" ? next.slice(-limit) : next; }); } else { id = showToast(loading); } const settle = (status: "success" | "error", content: ToastPromiseContent) => { const resolved = resolvePromiseContent(content); updateToast(id, { // Drop loading-phase extras the settled copy does not restate. description: undefined, action: undefined, icon: undefined, ...resolved, status, duration: resolved.duration ?? settledDuration, }); }; let result: Promise; try { result = typeof promise === "function" ? promise() : promise; } catch (error) { result = Promise.reject(error); } // Attaching both handlers here also keeps a rejection from surfacing as // unhandled when the caller ignores `result`. result.then( (data) => { const { success } = messages; settle("success", typeof success === "function" ? success(data) : success); }, (error: unknown) => { const { error: onError } = messages; settle("error", typeof onError === "function" ? onError(error) : onError); }, ); return { id, result }; }, [defaultDuration, limit, showToast, updateToast], ); 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, promiseToast, dismissToast, clearToasts, setToasts, }), [clearToasts, dismissToast, promiseToast, showToast, toasts, updateToast], ); } export function AnimatedToastStack({ toasts, onDismiss, position = "bottom-right", placement, fixed = false, portal, portalRoot, maxVisible = 4, className, classNames, icons, renderToast, }: AnimatedToastStackProps) { const [portalTarget, setPortalTarget] = useState(null); const visibleToasts = toasts.slice(-maxVisible); const isBottom = position.startsWith("bottom"); const resolvedPlacement = placement ?? (fixed ? "fixed" : "static"); const shouldPortal = portal ?? resolvedPlacement === "fixed"; useEffect(() => { setPortalTarget(shouldPortal ? (portalRoot ?? document.body) : null); }, [portalRoot, shouldPortal]); const stack = (
    {visibleToasts.map((toast, index) => ( ))}
); if (shouldPortal && !portalTarget) { return null; } if (shouldPortal && portalTarget) { return createPortal(stack, portalTarget); } 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 )} {/* layout="position" keeps the copy from stretching while the item's height morphs between phases (e.g. a promise toast settling). */}

{toast.title}

{toast.description ? (

{toast.description}

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