"use client"; import { useId, useMemo, useRef, useState, type ReactNode, type Ref, } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { calcGeneratorDuration, spring } from "motion"; import { ArrowRight, Check, Info, LoaderCircle, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { useMediaQuery } from "@/lib/use-media-query"; gsap.registerPlugin(useGSAP); export type ToastDismissReason = "dismiss" | "timeout" | "action"; export interface ToastAction { label: string; onClick: (signal: AbortSignal) => void | Promise; dismissOnSuccess?: boolean; errorMessage?: string; } export interface ToastItem { id: string; title: string; description?: string; tone?: "neutral" | "info" | "danger" | "success"; icon?: ReactNode; date?: { month: string; day: string }; badges?: readonly string[]; duration?: number; countdownLabel?: string; footer?: string; action?: ToastAction; secondaryAction?: ToastAction; actionPlacement?: "inline" | "footer"; dismiss?: ToastDismissReason; } export interface ToasterProps { items: readonly ToastItem[]; onDismiss: (id: string, reason: ToastDismissReason) => void; stiffness?: number; damping?: number; className?: string; ref?: Ref; } const bounded = (value: number, fallback: number, min: number, max: number) => Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; function useToastMotion(stiffness: number, damping: number) { const reduced = useMediaQuery("(prefers-reduced-motion: reduce)"); return useMemo(() => { const response = spring({ keyframes: [0, 1], stiffness: bounded(stiffness, 240, 80, 500), damping: bounded(damping, 28, 16, 50), restSpeed: 0.001, restDelta: 0.001, }); const duration = calcGeneratorDuration(response) / 1000; return { reduced, duration: reduced ? 0 : duration, ease: (progress: number) => progress === 1 ? 1 : response.next(progress * duration * 1000).value, }; }, [stiffness, damping, reduced]); } const buttonClass = "inline-flex min-h-8 cursor-pointer items-center justify-center gap-2 rounded-md px-1.5 text-[13px] font-medium outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-current disabled:cursor-wait disabled:opacity-50 dark:hover:bg-white/10"; function ToastCard({ item, onDismiss, motion, }: { item: ToastItem; onDismiss: ( id: string, reason: ToastDismissReason, restoreFocus: boolean, ) => void; motion: ReturnType; }) { const titleId = useId(); const cardRef = useRef(null); const secondsRef = useRef(null); const progressRef = useRef(null); const timerRef = useRef(null); const pausesRef = useRef(new Set()); const closingRef = useRef(false); const restoreFocusRef = useRef(false); const requestRef = useRef(null); const callbacksRef = useRef({ onDismiss }); const [closing, setClosing] = useState(null); const [busy, setBusy] = useState(null); const [error, setError] = useState(""); const { contextSafe } = useGSAP({ scope: cardRef }); const duration = bounded(item.duration ?? 0, 0, 0, 3600000); const tone = item.tone ?? "neutral"; const hasFooter = Boolean( item.footer || (item.countdownLabel && duration > 0) || (item.action && item.actionPlacement === "footer"), ); useGSAP( () => { callbacksRef.current = { onDismiss }; }, { dependencies: [onDismiss] }, ); const pause = (reason: string, active: boolean) => { if (active) pausesRef.current.add(reason); else pausesRef.current.delete(reason); timerRef.current?.paused(pausesRef.current.size > 0 || closingRef.current); }; const dismiss = (reason: ToastDismissReason) => { if (closingRef.current) return; restoreFocusRef.current ||= Boolean( cardRef.current?.contains(document.activeElement), ); closingRef.current = true; timerRef.current?.pause(); requestRef.current?.abort(); setClosing(reason); }; useGSAP( () => { if (item.dismiss) dismiss(item.dismiss); }, { dependencies: [item.dismiss], scope: cardRef }, ); useGSAP( () => { const reduced = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; gsap.fromTo( cardRef.current, { autoAlpha: 0, y: reduced ? 0 : 18, scale: reduced ? 1 : 0.97 }, { autoAlpha: 1, y: 0, scale: 1, duration: reduced ? 0 : motion.duration, ease: motion.ease, overwrite: "auto", }, ); return () => { requestRef.current?.abort(); }; }, { scope: cardRef }, ); useGSAP( () => { if (!closing) return; gsap.to(cardRef.current, { autoAlpha: 0, y: motion.reduced ? 0 : -8, scale: motion.reduced ? 1 : 0.97, duration: motion.reduced ? 0 : 0.18, ease: "power2.in", overwrite: true, onComplete: () => callbacksRef.current.onDismiss( item.id, closing, restoreFocusRef.current, ), }); }, { dependencies: [closing, motion], scope: cardRef, revertOnUpdate: true }, ); useGSAP( () => { if (!busy || motion.reduced) return; gsap.to("[data-toast-spinner]", { rotation: 360, duration: 0.8, ease: "none", repeat: -1, }); }, { dependencies: [busy, motion.reduced], scope: cardRef, revertOnUpdate: true, }, ); useGSAP( () => { if (!duration) return; const clock = { remaining: duration / 1000 }; let lastSecond = Math.ceil(clock.remaining); const timer = gsap.timeline({ paused: true, onComplete: () => dismiss("timeout"), }); timer.to( clock, { remaining: 0, duration: duration / 1000, ease: "none", onUpdate: () => { const second = Math.ceil(clock.remaining); if (second !== lastSecond && secondsRef.current) { secondsRef.current.textContent = `${second} ${second === 1 ? "second" : "seconds"}`; lastSecond = second; } }, }, 0, ); timer.fromTo( progressRef.current, { scaleX: 1 }, { scaleX: 0, duration: duration / 1000, ease: "none" }, 0, ); timerRef.current = timer; const visibility = () => pause("hidden", document.hidden); visibility(); document.addEventListener("visibilitychange", visibility); return () => { document.removeEventListener("visibilitychange", visibility); timerRef.current = null; }; }, { dependencies: [duration], scope: cardRef, revertOnUpdate: true }, ); const runAction = async (action: ToastAction) => { if (requestRef.current || closingRef.current) return; restoreFocusRef.current = Boolean( cardRef.current?.contains(document.activeElement), ); const request = new AbortController(); requestRef.current = request; pause("action", true); setBusy(action.label); setError(""); try { await action.onClick(request.signal); if (request.signal.aborted) return; if (action.dismissOnSuccess !== false) dismiss("action"); pause("error", false); } catch { if (!request.signal.aborted) { setError( action.errorMessage ?? "Couldn’t complete this action. Please try again.", ); pause("error", true); } } finally { if (!request.signal.aborted) { requestRef.current = null; setBusy(null); pause("action", false); } } }; const highlightFooter = contextSafe( (button: HTMLButtonElement, active: boolean) => { const highlight = button.querySelector("[data-toast-footer-highlight]"); if (!highlight) return; gsap.to(highlight, { opacity: active ? 1 : 0, duration: motion.reduced ? 0 : 0.18, ease: "power2.out", overwrite: "auto", }); }, ); const actionButton = (action: ToastAction, footer = false) => ( ); return (
{ if (event.pointerType !== "touch") pause("hover", true); }} onPointerLeave={() => pause("hover", false)} onFocusCapture={() => pause("focus", true)} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) pause("focus", false); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); dismiss("dismiss"); } }} >
{hasFooter && (
{item.countdownLabel && duration > 0 ? ( <> {item.countdownLabel} {Math.ceil(duration / 1000)}{" "} {Math.ceil(duration / 1000) === 1 ? "second" : "seconds"} ) : ( item.footer && ( {item.footer} ) )} {item.action && item.actionPlacement === "footer" && (
{actionButton(item.action, true)}
)}
)}
); } function measureToastPositions(root: HTMLDivElement | null) { const rows = Array.from( root?.querySelectorAll("[data-toast-id]") ?? [], ); return rows.map((row) => { const rect = row.getBoundingClientRect(); const currentX = Number(gsap.getProperty(row, "x")) || 0; const currentY = Number(gsap.getProperty(row, "y")) || 0; return { row, id: row.dataset.toastId ?? "", x: rect.left - currentX, y: rect.top - currentY, currentX, currentY, }; }); } export function Toaster({ items, onDismiss, stiffness = 240, damping = 28, className, ref, }: ToasterProps) { const rootRef = useRef(null); const positionsRef = useRef(new Map()); const restoreFocusRef = useRef(false); const motion = useToastMotion(stiffness, damping); const itemKey = JSON.stringify(items.map((item) => item.id)); useGSAP( () => { const positions = measureToastPositions(rootRef.current); for (const { row, id, x, y, currentX, currentY } of positions) { const previous = positionsRef.current.get(id); if (previous) { gsap.fromTo( row, { x: previous.x - x + currentX, y: previous.y - y + currentY }, { x: 0, y: 0, duration: motion.duration, ease: motion.ease, overwrite: true, }, ); } } positionsRef.current = new Map( positions.map(({ id, x, y }) => [id, { x, y }]), ); if (restoreFocusRef.current) { ( positions[0]?.row.querySelector( "button:not(:disabled)", ) ?? rootRef.current )?.focus({ preventScroll: true }); restoreFocusRef.current = false; } }, { dependencies: [itemKey, motion, className], scope: rootRef }, ); return (
{ rootRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; }} role="region" aria-label="Notifications" tabIndex={-1} className="w-full outline-none focus-visible:ring-2 focus-visible:ring-zinc-400" >
    {items.map((item) => (
  1. { positionsRef.current = new Map( measureToastPositions(rootRef.current).map( ({ id: toastId, x, y }) => [toastId, { x, y }], ), ); const row = Array.from( rootRef.current?.querySelectorAll( "[data-toast-id]", ) ?? [], ).find((element) => element.dataset.toastId === id); const activeElement = document.activeElement; const containsFocus = Boolean(row?.contains(activeElement)); restoreFocusRef.current ||= containsFocus || (restoreFocus && activeElement === document.body); onDismiss(id, reason); }} />
  2. ))}
{items.map((item) => item.title).join(". ")}
); } export default Toaster;