"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type PanInfo } from "motion/react";
/* ---------------------------------- types ---------------------------------- */
export type ToastType = "default" | "success" | "error" | "info" | "loading";
export type ToastPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right";
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface ToastOptions {
/** Reuse an id to replace an existing toast. */
id?: string;
title: string;
description?: string;
type?: ToastType;
/** Auto-dismiss delay in ms. Infinity keeps the toast until dismissed. Defaults to the stack's duration. */
duration?: number;
/** Optional action button. The toast closes after the action runs. */
action?: ToastAction;
}
export interface ToastRecord {
id: string;
title: string;
description?: string;
type: ToastType;
duration?: number;
action?: ToastAction;
/** Increments on every update, restarting the auto-dismiss timer. */
version: number;
}
export interface ToastPromiseMessages<T> {
loading: string;
success: string | ((value: T) => string);
error: string | ((error: unknown) => string);
}
export interface ToastController {
/** Toasts, newest first. */
toasts: ToastRecord[];
/** Latest message for the live regions (used by ToastStack). */
announcement: { text: string; assertive: boolean; key: number };
/** Shows a toast and returns its id. */
toast: (options: ToastOptions) => string;
/** Updates an existing toast (e.g. loading → success) and restarts its timer. */
update: (id: string, options: Partial<Omit<ToastOptions, "id">>) => void;
/** Dismisses one toast, or all of them when called without an id. */
dismiss: (id?: string) => void;
/** Shows a loading toast that resolves to success or error with the promise. Returns the same promise. */
promise: <T>(promise: Promise<T>, messages: ToastPromiseMessages<T>) => Promise<T>;
}
let counter = 0;
const nextId = () => `toast-${++counter}`;
const toRecord = (options: ToastOptions): ToastRecord => ({
id: options.id ?? nextId(),
title: options.title,
description: options.description,
type: options.type ?? "default",
duration: options.duration,
action: options.action,
version: 0,
});
/**
* Creates a toast controller scoped to the calling component. Pass it to <ToastStack toaster={...} />
* and call `toast()`, `update()`, `promise()` or `dismiss()` from event handlers.
*/
export function useToastStack(initial?: ToastOptions[]): ToastController {
const [toasts, setToasts] = React.useState<ToastRecord[]>(() => (initial ?? []).map(toRecord));
const [announcement, setAnnouncement] = React.useState({ text: "", assertive: false, key: 0 });
const announce = (text: string, assertive: boolean) => setAnnouncement((prev) => ({ text, assertive, key: prev.key + 1 }));
const update: ToastController["update"] = (id, options) => {
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, ...options, version: t.version + 1 } : t)));
if (options.title) announce(options.title, options.type === "error");
};
const toast: ToastController["toast"] = (options) => {
const record = toRecord(options);
setToasts((prev) =>
prev.some((t) => t.id === record.id)
? prev.map((t) => (t.id === record.id ? { ...record, version: t.version + 1 } : t))
: [record, ...prev],
);
announce(options.description ? `${options.title}. ${options.description}` : options.title, record.type === "error");
return record.id;
};
const dismiss: ToastController["dismiss"] = (id) => {
setToasts((prev) => (id === undefined ? [] : prev.filter((t) => t.id !== id)));
};
function promise<T>(source: Promise<T>, messages: ToastPromiseMessages<T>): Promise<T> {
const id = toast({ type: "loading", title: messages.loading, duration: Infinity });
source.then(
(value) =>
update(id, {
type: "success",
title: typeof messages.success === "function" ? messages.success(value) : messages.success,
duration: undefined,
}),
(error: unknown) =>
update(id, {
type: "error",
title: typeof messages.error === "function" ? messages.error(error) : messages.error,
duration: undefined,
}),
);
return source;
}
return { toasts, announcement, toast, update, dismiss, promise };
}
/* ---------------------------------- icons ---------------------------------- */
function TypeIcon({ type }: { type: ToastType }) {
const base = "mt-px h-[18px] w-[18px] shrink-0";
switch (type) {
case "success":
return (
<svg viewBox="0 0 20 20" className={`${base} text-emerald-600 dark:text-[#B6F3D1]`} aria-hidden="true">
<circle cx="10" cy="10" r="8" fill="currentColor" fillOpacity="0.14" />
<path d="m6.5 10.2 2.3 2.3 4.7-4.9" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
case "error":
return (
<svg viewBox="0 0 20 20" className={`${base} text-red-600 dark:text-red-400`} aria-hidden="true">
<circle cx="10" cy="10" r="8" fill="currentColor" fillOpacity="0.14" />
<path d="M10 6v4.8M10 13.6v.1" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
case "info":
return (
<svg viewBox="0 0 20 20" className={`${base} text-[var(--ts-accent)]`} aria-hidden="true">
<circle cx="10" cy="10" r="8" fill="currentColor" fillOpacity="0.16" />
<path d="M10 9.2V14M10 6.3v.1" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
case "loading":
return (
<svg viewBox="0 0 20 20" className={`${base} animate-spin text-zinc-500 motion-reduce:animate-none dark:text-zinc-400`} aria-hidden="true">
<circle cx="10" cy="10" r="7" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="2" />
<path d="M17 10a7 7 0 0 0-7-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
default:
return null;
}
}
/* ---------------------------------- toast ---------------------------------- */
interface ToastItemProps {
toast: ToastRecord;
index: number;
count: number;
expanded: boolean;
paused: boolean;
/** Offset from the stack edge in px (positive = away from the edge). */
offset: number;
/** Height applied while collapsed behind the front toast; undefined = natural height. */
clampHeight: number | undefined;
hiddenBehind: boolean;
isBottom: boolean;
duration: number;
swipeToDismiss: boolean;
showProgress: boolean;
closeLabel: string;
spring: { type: "spring"; stiffness: number; damping: number; mass: number } | { duration: number };
reduceMotion: boolean;
onHeight: (id: string, height: number) => void;
onDismiss: (id: string, viaKeyboard: boolean) => void;
}
function ToastItem({
toast,
index,
count,
expanded,
paused,
offset,
clampHeight,
hiddenBehind,
isBottom,
duration,
swipeToDismiss,
showProgress,
closeLabel,
spring,
reduceMotion,
onHeight,
onDismiss,
}: ToastItemProps) {
const uid = React.useId();
const x = useMotionValue(0);
const contentOpacity = useTransform(x, [-180, 0, 180], [0, 1, 0]);
const barRef = React.useRef<HTMLSpanElement>(null);
const animationRef = React.useRef<Animation | null>(null);
const total = toast.duration ?? duration;
const timed = toast.type !== "loading" && Number.isFinite(total) && total > 0;
const remainingRef = React.useRef(total);
const dismissFromTimer = React.useEffectEvent(() => onDismiss(toast.id, false));
// A new version (update) or duration restarts the countdown.
React.useEffect(() => {
remainingRef.current = total;
}, [toast.version, total]);
// Auto-dismiss that pauses (keeping the remaining time) while hovered, focused or the tab is hidden.
React.useEffect(() => {
if (!timed || paused) return;
const started = performance.now();
const timer = window.setTimeout(() => dismissFromTimer(), Math.max(0, remainingRef.current));
return () => {
window.clearTimeout(timer);
remainingRef.current -= performance.now() - started;
};
}, [timed, paused, toast.version, total]);
// Progress hairline mirrors the countdown with the Web Animations API.
React.useEffect(() => {
const bar = barRef.current;
if (!bar || !timed) return;
const animation = bar.animate([{ transform: "scaleX(1)" }, { transform: "scaleX(0)" }], {
duration: total,
easing: "linear",
fill: "forwards",
});
animationRef.current = animation;
return () => {
animation.cancel();
animationRef.current = null;
};
}, [timed, toast.version, total]);
React.useEffect(() => {
const animation = animationRef.current;
if (!animation) return;
if (paused) animation.pause();
else animation.play();
}, [paused, toast.version, timed]);
const measure = (node: HTMLDivElement | null) => {
if (!node) return;
const observer = new ResizeObserver(() => onHeight(toast.id, node.offsetHeight));
observer.observe(node);
return () => observer.disconnect();
};
const handleDragEnd = (_: PointerEvent, info: PanInfo) => {
if (Math.abs(info.offset.x) > 80 || Math.abs(info.velocity.x) > 600) {
const direction = Math.sign(info.offset.x || info.velocity.x) || 1;
animate(x, direction * 420, reduceMotion ? { duration: 0 } : { duration: 0.2, ease: "easeOut" }).then(() =>
onDismiss(toast.id, false),
);
} else {
animate(x, 0, reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 });
}
};
const isFront = index === 0;
const scale = expanded || reduceMotion ? 1 : 1 - Math.min(index, 3) * 0.05;
const enterY = isBottom ? 16 : -16;
return (
<motion.li
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: enterY, scale: 0.96 }}
animate={{
opacity: hiddenBehind ? 0 : 1,
y: isBottom ? -offset : offset,
scale,
height: clampHeight ?? "auto",
}}
exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: 0.94, transition: { duration: 0.18 } }}
transition={spring}
drag={swipeToDismiss && (expanded || isFront) ? "x" : false}
dragDirectionLock
dragMomentum={false}
onDragEnd={handleDragEnd}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
onDismiss(toast.id, true);
}
}}
// Toasts faded out behind the stack are inert until it expands.
inert={hiddenBehind}
aria-labelledby={`${uid}-title`}
aria-describedby={toast.description ? `${uid}-description` : undefined}
className={`absolute inset-x-0 overflow-hidden rounded-xl border border-zinc-900/10 bg-white text-zinc-900 shadow-[0_10px_30px_-14px_rgba(0,0,0,0.3),0_1px_2px_rgba(0,0,0,0.04)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-[0_10px_30px_-14px_rgba(0,0,0,0.85)] ${
isBottom ? "bottom-0 origin-top" : "top-0 origin-bottom"
} ${hiddenBehind ? "pointer-events-none" : ""} ${swipeToDismiss ? "touch-pan-y" : ""}`}
style={{ x, zIndex: count - index }}
>
<motion.div
ref={measure}
// Content of toasts peeking behind the front one is hidden; the card itself stays visible.
animate={{ opacity: expanded || isFront ? 1 : 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.2 }}
>
<motion.div style={{ opacity: contentOpacity }} className="flex items-start gap-3 py-3.5 pl-3.5 pr-10">
<TypeIcon type={toast.type} />
<div className="min-w-0 flex-1">
<p id={`${uid}-title`} className="text-[13.5px] font-medium leading-snug tracking-tight">
{toast.title}
</p>
{toast.description && (
<p id={`${uid}-description`} className="mt-0.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400">
{toast.description}
</p>
)}
</div>
{toast.action && (
<button
type="button"
onClick={() => {
toast.action?.onClick();
onDismiss(toast.id, false);
}}
className="shrink-0 self-center rounded-md bg-[var(--ts-accent)] px-2.5 py-1 text-[12px] font-medium text-zinc-950 transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ts-accent)]"
>
{toast.action.label}
</button>
)}
</motion.div>
</motion.div>
<button
type="button"
aria-label={`${closeLabel}: ${toast.title}`}
onClick={(event) => onDismiss(toast.id, event.detail === 0)}
className="absolute right-2 top-2.5 flex h-6 w-6 items-center justify-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[var(--ts-accent)] dark:text-zinc-500 dark:hover:bg-white/[0.08] dark:hover:text-white"
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" aria-hidden="true">
<path d="m4.5 4.5 7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
{showProgress && timed && (
<span
ref={barRef}
aria-hidden="true"
className={`absolute inset-x-3.5 bottom-0 h-px origin-left bg-[var(--ts-accent)] ${expanded || isFront ? "opacity-70" : "opacity-0"}`}
/>
)}
</motion.li>
);
}
/* ---------------------------------- stack ---------------------------------- */
export interface ToastStackProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** Controller returned by useToastStack(). */
toaster: ToastController;
/** Corner or edge the stack is anchored to. */
position?: ToastPosition;
/** Default auto-dismiss delay in ms. */
duration?: number;
/** Toasts visible in the collapsed stack (older ones fade out behind). */
visibleToasts?: number;
/** Always show the stack expanded as a list. */
expand?: boolean;
/** Allow dismissing toasts with a horizontal swipe. */
swipeToDismiss?: boolean;
/** Show a hairline counting down each toast's timer. */
showProgress?: boolean;
/** "fixed" anchors to the viewport; "absolute" anchors to the nearest positioned ancestor. */
strategy?: "fixed" | "absolute";
/** Distance from the anchored edges in px. */
offset?: number;
/** Gap between expanded toasts in px. */
gap?: number;
/** Width of the stack in px (shrinks on narrow screens). */
width?: number;
/** Accent for info icons, actions, progress and focus rings. */
accent?: string;
/** Accessible name of the notifications region. */
label?: string;
/** Accessible name prefix of each dismiss button. */
closeLabel?: string;
}
const PEEK = 14;
function useDocumentHidden() {
return React.useSyncExternalStore(
(onChange) => {
document.addEventListener("visibilitychange", onChange);
return () => document.removeEventListener("visibilitychange", onChange);
},
() => document.hidden,
() => false,
);
}
/**
* A stack of notifications: the newest sits in front with older ones peeking behind; hover or focus
* fans the stack out into a list. Timers pause while the stack is hovered, focused or the tab is hidden.
*/
export function ToastStack({
toaster,
position = "bottom-right",
duration = 4000,
visibleToasts = 3,
expand = false,
swipeToDismiss = true,
showProgress = true,
strategy = "fixed",
offset = 20,
gap = 10,
width = 356,
accent = "#FF7448",
label = "Notifications",
closeLabel = "Dismiss notification",
className = "",
style,
...props
}: ToastStackProps) {
const { toasts, announcement, dismiss } = toaster;
const reduceMotion = useReducedMotion() ?? false;
const documentHidden = useDocumentHidden();
const regionRef = React.useRef<HTMLElement>(null);
const [hovered, setHovered] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const [touchOpen, setTouchOpen] = React.useState(false);
const [heights, setHeights] = React.useState<Record<string, number>>({});
const isBottom = position.startsWith("bottom");
const expanded = expand || hovered || focused || touchOpen;
const paused = hovered || focused || touchOpen || documentHidden;
// Touch has no hover: a tap fans the stack out and a tap elsewhere folds it back.
React.useEffect(() => {
if (!touchOpen) return;
const onDown = (event: PointerEvent) => {
if (!regionRef.current?.contains(event.target as Node)) setTouchOpen(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [touchOpen]);
const onHeight = (id: string, height: number) =>
setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }));
const onDismiss = (id: string, viaKeyboard: boolean) => {
// Keep keyboard users inside the region instead of dropping focus on <body>.
if (viaKeyboard) regionRef.current?.focus({ preventScroll: true });
dismiss(id);
};
const heightOf = (id: string) => heights[id] ?? 64;
const frontHeight = toasts.length ? heightOf(toasts[0].id) : 0;
const shown = Math.min(toasts.length, visibleToasts);
// Offsets from the anchored edge for every toast.
const offsets: number[] = [];
let stacked = 0;
for (let i = 0; i < toasts.length; i++) {
offsets.push(expanded ? stacked : Math.min(i, visibleToasts - 1) * PEEK);
stacked += heightOf(toasts[i].id) + gap;
}
const listHeight = !toasts.length ? 0 : expanded ? stacked - gap : frontHeight + (shown - 1) * PEEK;
const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 36, mass: 0.9 };
const [vertical, horizontal] = position.split("-") as ["top" | "bottom", "left" | "center" | "right"];
const placement: React.CSSProperties = {
[vertical]: offset,
...(horizontal === "left" ? { left: offset } : horizontal === "right" ? { right: offset } : { left: "50%", translate: "-50% 0" }),
width: `min(${width}px, calc(100% - ${offset * 2}px))`,
};
return (
<section
ref={regionRef}
aria-label={label}
tabIndex={-1}
className={`${strategy === "fixed" ? "fixed" : "absolute"} z-[100] outline-none ${className}`}
style={{ ["--ts-accent" as string]: accent, ...placement, ...style }}
onFocus={() => setFocused(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
}}
{...props}
>
{/* Persistent live regions: errors interrupt, everything else waits its turn. */}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{!announcement.assertive && <span key={announcement.key}>{announcement.text}</span>}
</div>
<div role="alert" aria-live="assertive" aria-atomic="true" className="sr-only">
{announcement.assertive && <span key={announcement.key}>{announcement.text}</span>}
</div>
<motion.ol
initial={false}
animate={{ height: listHeight }}
transition={spring}
className="relative"
onPointerEnter={(event) => {
if (event.pointerType === "mouse") setHovered(true);
}}
onPointerLeave={(event) => {
if (event.pointerType === "mouse") setHovered(false);
}}
onPointerDown={(event) => {
if (event.pointerType !== "mouse") setTouchOpen(true);
}}
>
<AnimatePresence initial={false}>
{toasts.map((toast, i) => (
<ToastItem
key={toast.id}
toast={toast}
index={i}
count={toasts.length}
expanded={expanded}
paused={paused}
offset={offsets[i]}
clampHeight={!expanded && i > 0 ? frontHeight : undefined}
hiddenBehind={!expanded && i >= visibleToasts}
isBottom={isBottom}
duration={duration}
swipeToDismiss={swipeToDismiss}
showProgress={showProgress}
closeLabel={closeLabel}
spring={spring}
reduceMotion={reduceMotion}
onHeight={onHeight}
onDismiss={onDismiss}
/>
))}
</AnimatePresence>
</motion.ol>
</section>
);
}