"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react";
import { CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------ */
/* Store — a tiny external store so `toast()` works from anywhere. */
/* ------------------------------------------------------------------ */
export type ToastType = "default" | "success" | "error" | "info" | "loading";
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface ToastOptions {
id?: string;
description?: React.ReactNode;
/** ms before auto-dismiss. `Infinity` keeps it open. Falls back to the Toaster's `duration`. */
duration?: number;
action?: ToastAction;
}
export interface ToastData extends ToastOptions {
id: string;
type: ToastType;
title: React.ReactNode;
/** Bumped whenever the toast is updated, so timers restart. */
version: number;
}
type Listener = () => void;
let toasts: ToastData[] = [];
const listeners = new Set<Listener>();
let seq = 0;
const EMPTY: ToastData[] = [];
function emit() {
listeners.forEach((l) => l());
}
function upsert(type: ToastType, title: React.ReactNode, opts: ToastOptions = {}) {
const id = opts.id ?? `t${++seq}`;
const existing = toasts.find((t) => t.id === id);
if (existing) {
toasts = toasts.map((t) => (t.id === id ? { ...t, ...opts, id, type, title, version: t.version + 1 } : t));
} else {
toasts = [...toasts, { ...opts, id, type, title, version: 0 }];
}
emit();
return id;
}
function dismiss(id?: string) {
toasts = id ? toasts.filter((t) => t.id !== id) : [];
emit();
}
type PromiseMessages<T> = {
loading: React.ReactNode;
success: React.ReactNode | ((value: T) => React.ReactNode);
error?: React.ReactNode | ((error: unknown) => React.ReactNode);
description?: React.ReactNode;
};
function promiseToast<T>(promise: Promise<T> | (() => Promise<T>), msgs: PromiseMessages<T>, opts: ToastOptions = {}) {
const id = upsert("loading", msgs.loading, { ...opts, duration: Infinity });
const p = typeof promise === "function" ? promise() : promise;
p.then(
(v) => upsert("success", typeof msgs.success === "function" ? (msgs.success as (value: T) => React.ReactNode)(v) : msgs.success, { ...opts, id, duration: opts.duration }),
(e) =>
upsert("error", typeof msgs.error === "function" ? (msgs.error as (error: unknown) => React.ReactNode)(e) : (msgs.error ?? "Something went wrong"), {
...opts,
id,
duration: opts.duration,
}),
);
return p;
}
/** Imperative API: `toast("Saved")`, `toast.success(...)`, `toast.promise(fetch(...), {...})`. */
export const toast = Object.assign((title: React.ReactNode, opts?: ToastOptions) => upsert("default", title, opts), {
success: (title: React.ReactNode, opts?: ToastOptions) => upsert("success", title, opts),
error: (title: React.ReactNode, opts?: ToastOptions) => upsert("error", title, opts),
info: (title: React.ReactNode, opts?: ToastOptions) => upsert("info", title, opts),
loading: (title: React.ReactNode, opts?: ToastOptions) => upsert("loading", title, { duration: Infinity, ...opts }),
promise: promiseToast,
dismiss,
});
const subscribe = (l: Listener) => {
listeners.add(l);
return () => listeners.delete(l);
};
export function useToast() {
const list = React.useSyncExternalStore(subscribe, () => toasts, () => EMPTY);
return { toasts: list, toast, dismiss };
}
/* ------------------------------------------------------------------ */
/* Toaster */
/* ------------------------------------------------------------------ */
export type ToasterPosition = "bottom-right" | "bottom-center" | "bottom-left" | "top-right" | "top-center" | "top-left";
export interface ToasterProps {
position?: ToasterPosition;
/** Default auto-dismiss time in ms. */
duration?: number;
/** Toasts visible in the collapsed stack. */
visibleToasts?: number;
/** Always show the stack expanded. */
expand?: boolean;
/** Gap between expanded toasts (px). */
gap?: number;
className?: string;
}
const PEEK = 14;
const positions: Record<ToasterPosition, string> = {
"bottom-right": "bottom-4 right-4",
"bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
"bottom-left": "bottom-4 left-4",
"top-right": "top-4 right-4",
"top-center": "top-4 left-1/2 -translate-x-1/2",
"top-left": "top-4 left-4",
};
export function Toaster({
position = "bottom-right",
duration = 4000,
visibleToasts = 3,
expand = false,
gap = 10,
className,
}: ToasterProps) {
const { toasts: list } = useToast();
const [hovered, setHovered] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const [heights, setHeights] = React.useState<Record<string, number>>({});
const bottom = position.startsWith("bottom");
const interacting = (hovered || focused) && list.length > 0;
const expanded = expand || interacting;
const ordered = React.useMemo(() => [...list].reverse(), [list]);
const onHeight = React.useCallback((id: string, h: number) => {
setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
}, []);
const frontH = ordered[0] ? (heights[ordered[0].id] ?? 64) : 0;
const shown = ordered.slice(0, expanded ? ordered.length : visibleToasts);
const offsets: number[] = [];
let acc = 0;
ordered.forEach((t, i) => {
offsets.push(expanded ? acc : Math.min(i, visibleToasts - 1) * PEEK);
acc += (heights[t.id] ?? 64) + gap;
});
const stackHeight = expanded ? Math.max(0, acc - gap) : frontH + (Math.min(shown.length, visibleToasts) - 1) * PEEK;
return (
<section
aria-label="Notifications"
className={cn(
"fixed z-[100] w-[min(22.5rem,calc(100vw-2rem))] max-sm:left-4 max-sm:right-4 max-sm:w-auto max-sm:translate-x-0",
positions[position],
className,
)}
>
<motion.ol
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
}}
animate={{ height: Math.max(stackHeight, 0) }}
transition={{ type: "spring", stiffness: 400, damping: 36 }}
className="relative"
>
<AnimatePresence initial={false}>
{ordered.map((t, i) => (
<ToastItem
key={t.id}
toast={t}
index={i}
offset={offsets[i]}
expanded={expanded}
hidden={!expanded && i >= visibleToasts}
frontHeight={frontH}
height={heights[t.id]}
bottom={bottom}
paused={interacting}
duration={t.duration ?? duration}
onHeight={onHeight}
/>
))}
</AnimatePresence>
</motion.ol>
</section>
);
}
const icons: Record<ToastType, React.ReactNode> = {
default: null,
success: <CheckCircle2 className="size-5 text-emerald-500" />,
error: <XCircle className="size-5 text-red-500" />,
info: <Info className="size-5 text-sky-500" />,
loading: <Loader2 className="size-5 animate-spin text-muted-foreground" />,
};
interface ToastItemProps {
toast: ToastData;
index: number;
offset: number;
expanded: boolean;
hidden: boolean;
frontHeight: number;
height?: number;
bottom: boolean;
paused: boolean;
duration: number;
onHeight: (id: string, h: number) => void;
}
function ToastItem({ toast: t, index, offset, expanded, hidden, frontHeight, height, bottom, paused, duration, onHeight }: ToastItemProps) {
const reduce = useReducedMotion();
const contentRef = React.useRef<HTMLDivElement>(null);
const [swipe, setSwipe] = React.useState(0);
const remaining = React.useRef(duration);
const behind = !expanded && index > 0;
React.useEffect(() => {
const el = contentRef.current;
if (!el) return;
const ro = new ResizeObserver(() => onHeight(t.id, el.offsetHeight));
ro.observe(el);
return () => ro.disconnect();
}, [t.id, onHeight]);
// Restart the countdown whenever the toast is (re)published, e.g. loading → success.
React.useEffect(() => {
remaining.current = duration;
}, [t.version, duration]);
React.useEffect(() => {
if (paused || t.type === "loading" || !Number.isFinite(remaining.current)) return;
const started = Date.now();
const timer = setTimeout(() => dismiss(t.id), Math.max(0, remaining.current));
return () => {
clearTimeout(timer);
remaining.current -= Date.now() - started;
};
}, [paused, t.type, t.id, t.version]);
const onDragEnd = (_: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => {
if (Math.abs(info.offset.x) > 90 || Math.abs(info.velocity.x) > 600) {
setSwipe(Math.sign(info.offset.x || info.velocity.x));
dismiss(t.id);
}
};
const dir = bottom ? -1 : 1;
const targetHeight = behind ? frontHeight : (height ?? "auto");
return (
<motion.li
role={t.type === "error" ? "alert" : "status"}
aria-live={t.type === "error" ? "assertive" : "polite"}
aria-atomic
initial={reduce ? { opacity: 0 } : { opacity: 0, y: dir * -48, scale: 0.96 }}
animate={{
opacity: hidden ? 0 : 1,
y: dir * offset,
scale: expanded || reduce ? 1 : 1 - Math.min(index, 3) * 0.05,
height: targetHeight,
}}
exit={
swipe
? { opacity: 0, x: swipe * 360, transition: { duration: 0.25 } }
: { opacity: 0, scale: 0.94, y: dir * offset + dir * -16, transition: { duration: 0.2 } }
}
transition={{ type: "spring", stiffness: 380, damping: 34 }}
drag={reduce ? false : "x"}
dragSnapToOrigin
dragElastic={0.5}
onDragEnd={onDragEnd}
style={{ zIndex: 50 - index, transformOrigin: bottom ? "bottom center" : "top center", pointerEvents: hidden ? "none" : undefined }}
className={cn(
"group absolute inset-x-0 cursor-grab touch-pan-y overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg shadow-black/5 active:cursor-grabbing dark:shadow-black/40",
bottom ? "bottom-0" : "top-0",
)}
>
<motion.div
ref={contentRef}
animate={{ opacity: behind ? 0 : 1 }}
transition={{ duration: 0.15 }}
className="flex items-start gap-3 p-4 pr-10"
>
{icons[t.type] && (
<span className="relative mt-px grid size-5 shrink-0 place-items-center">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={t.type}
initial={{ scale: 0.4, opacity: 0, rotate: -30 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
exit={{ scale: 0.4, opacity: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
className="grid place-items-center"
>
{icons[t.type]}
</motion.span>
</AnimatePresence>
</span>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium leading-5">{t.title}</p>
{t.description && <p className="mt-0.5 text-sm leading-5 text-muted-foreground">{t.description}</p>}
{t.action && (
<button
type="button"
onClick={() => {
t.action?.onClick();
dismiss(t.id);
}}
className="mt-2 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring"
>
{t.action.label}
</button>
)}
</div>
</motion.div>
<button
type="button"
aria-label="Dismiss notification"
onClick={() => dismiss(t.id)}
tabIndex={behind ? -1 : 0}
className="absolute right-2 top-2 rounded-md p-1.5 text-muted-foreground opacity-0 outline-none transition hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 max-sm:opacity-100"
>
<X className="size-3.5" />
</button>
</motion.li>
);
}