Animated Toast Stack
Notifications that gather into a compact stack in any of six screen positions: toasts spring in, change status in place (loading to success or error), leave with a sideways swipe or close button, and can carry an action. A useAnimatedToastStack hook handles ids, timers and promises.
Animated Toast Stack
animated-toast-stack.tsxThe base stack: neutral, info, success, error and loading toasts with icons, action buttons and swipe-away, placed top or bottom at the left, center or right, with a cap on how many stay visible.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/animated-toast-stack
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<AnimatedToast, "id" | "createdAt"> & {
id?: string;
};
/** Copy for one phase of a promise toast: a plain title, or a partial toast. */
export type ToastPromiseContent =
| ReactNode
| (Omit<ToastInput, "id" | "status"> & { title: ReactNode });
export type ToastPromiseMessages<T> = {
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<T> = {
id: string;
/** The original promise, so callers can still `await` the work. */
result: Promise<T>;
};
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<Record<ToastStatus, ReactNode>>;
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<ToastStatus, LucideIcon> = {
neutral: Bell,
info: Info,
loading: LoaderCircle,
success: Check,
error: AlertCircle,
};
const STATUS_CLASS: Record<ToastStatus, string> = {
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<ToastPosition, string> = {
"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<ToastInput, "id" | "status"> & { 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<Map<string, { timer: number; signature: string }>>(new Map());
const [toasts, setToasts] = useState<AnimatedToast[]>(() =>
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<ToastInput>) => {
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(
<T,>(
promise: Promise<T> | (() => Promise<T>),
messages: ToastPromiseMessages<T>,
): ToastPromiseResult<T> => {
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<T>;
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<Element | null>(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 = (
<ol
aria-live="polite"
aria-atomic="false"
className={cn(
"pointer-events-none flex w-[calc(100vw-2rem)] max-w-sm gap-2",
isBottom ? "flex-col-reverse" : "flex-col",
resolvedPlacement === "fixed" && "fixed z-[90]",
resolvedPlacement === "absolute" && "absolute z-20",
resolvedPlacement !== "static" && POSITION_CLASS[position],
classNames?.root,
className,
)}
>
<AnimatePresence initial={false} mode="popLayout">
{visibleToasts.map((toast, index) => (
<ToastItem
key={toast.id}
toast={toast}
index={index}
onDismiss={onDismiss}
classNames={classNames}
icons={icons}
renderToast={renderToast}
/>
))}
</AnimatePresence>
</ol>
);
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<Record<ToastStatus, ReactNode>>;
renderToast?: (toast: AnimatedToast) => ReactNode;
}) {
const reduce = useReducedMotion();
const status = toast.status ?? "neutral";
const Icon = STATUS_ICON[status];
const iconNode = icons?.[status] ?? toast.icon ?? <Icon className="h-3.5 w-3.5" />;
const canDismiss = toast.dismissible !== false && Boolean(onDismiss);
return (
<motion.li
layout
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 22, scale: 0.96, filter: "blur(10px)" }
}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: {
opacity: 0,
x: 32,
scale: 0.96,
filter: "blur(8px)",
transition: { duration: 0.18, ease: EASE_OUT },
}
}
transition={STACK_SPRING}
drag={canDismiss && !reduce ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
onDragEnd={(_, info) => {
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 }}
>
<div
className={cn(
"relative overflow-hidden rounded-2xl border border-border bg-card/95 p-3 shadow-2xl backdrop-blur-xl",
classNames?.surface,
)}
>
{renderToast ? (
renderToast(toast)
) : (
<div className="flex items-start gap-3">
<motion.span
layout
className={cn(
"mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
STATUS_CLASS[status],
classNames?.iconWrap,
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 8, scale: 0.8, filter: "blur(6px)" }
}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -8, scale: 0.9, filter: "blur(6px)" }
}
transition={CONTENT_TRANSITION}
className="inline-flex"
>
{status === "loading" ? (
<span className="inline-flex animate-spin">{iconNode}</span>
) : (
iconNode
)}
</motion.span>
</AnimatePresence>
</motion.span>
{/* layout="position" keeps the copy from stretching while the item's
height morphs between phases (e.g. a promise toast settling). */}
<motion.div layout="position" className={cn("min-w-0 flex-1", classNames?.content)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={`${toast.id}-${status}-${String(toast.title)}`}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 8, filter: "blur(6px)" }
}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -8, filter: "blur(6px)" }
}
transition={CONTENT_TRANSITION}
>
<p
className={cn(
"truncate text-sm font-medium leading-5 text-foreground",
classNames?.title,
)}
>
{toast.title}
</p>
{toast.description ? (
<p
className={cn(
"mt-0.5 line-clamp-2 text-xs leading-4 text-muted-foreground",
classNames?.description,
)}
>
{toast.description}
</p>
) : null}
</motion.div>
</AnimatePresence>
{toast.action ? (
<button
type="button"
onClick={() => toast.action?.onClick(toast)}
className={cn(
"mt-2 inline-flex h-7 items-center rounded-full bg-primary/[0.06] px-3 text-xs font-medium text-foreground transition-colors hover:bg-primary/[0.1]",
classNames?.action,
)}
>
{toast.action.label}
</button>
) : null}
</motion.div>
{canDismiss ? (
<button
type="button"
onClick={() => onDismiss?.(toast.id)}
aria-label="Dismiss toast"
className={cn(
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-primary/[0.06] hover:text-foreground",
classNames?.close,
)}
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
)}
</div>
</motion.li>
);
});
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
Toast Promise
animated-toast-stack.tsxHand a promise to the hook and a single toast shows a spinner, then turns into a success or error message in the same spot once it settles, and only then starts counting down to close.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/animated-toast-stack
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<AnimatedToast, "id" | "createdAt"> & {
id?: string;
};
/** Copy for one phase of a promise toast: a plain title, or a partial toast. */
export type ToastPromiseContent =
| ReactNode
| (Omit<ToastInput, "id" | "status"> & { title: ReactNode });
export type ToastPromiseMessages<T> = {
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<T> = {
id: string;
/** The original promise, so callers can still `await` the work. */
result: Promise<T>;
};
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<Record<ToastStatus, ReactNode>>;
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<ToastStatus, LucideIcon> = {
neutral: Bell,
info: Info,
loading: LoaderCircle,
success: Check,
error: AlertCircle,
};
const STATUS_CLASS: Record<ToastStatus, string> = {
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<ToastPosition, string> = {
"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<ToastInput, "id" | "status"> & { 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<Map<string, { timer: number; signature: string }>>(new Map());
const [toasts, setToasts] = useState<AnimatedToast[]>(() =>
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<ToastInput>) => {
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(
<T,>(
promise: Promise<T> | (() => Promise<T>),
messages: ToastPromiseMessages<T>,
): ToastPromiseResult<T> => {
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<T>;
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<Element | null>(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 = (
<ol
aria-live="polite"
aria-atomic="false"
className={cn(
"pointer-events-none flex w-[calc(100vw-2rem)] max-w-sm gap-2",
isBottom ? "flex-col-reverse" : "flex-col",
resolvedPlacement === "fixed" && "fixed z-[90]",
resolvedPlacement === "absolute" && "absolute z-20",
resolvedPlacement !== "static" && POSITION_CLASS[position],
classNames?.root,
className,
)}
>
<AnimatePresence initial={false} mode="popLayout">
{visibleToasts.map((toast, index) => (
<ToastItem
key={toast.id}
toast={toast}
index={index}
onDismiss={onDismiss}
classNames={classNames}
icons={icons}
renderToast={renderToast}
/>
))}
</AnimatePresence>
</ol>
);
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<Record<ToastStatus, ReactNode>>;
renderToast?: (toast: AnimatedToast) => ReactNode;
}) {
const reduce = useReducedMotion();
const status = toast.status ?? "neutral";
const Icon = STATUS_ICON[status];
const iconNode = icons?.[status] ?? toast.icon ?? <Icon className="h-3.5 w-3.5" />;
const canDismiss = toast.dismissible !== false && Boolean(onDismiss);
return (
<motion.li
layout
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 22, scale: 0.96, filter: "blur(10px)" }
}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: {
opacity: 0,
x: 32,
scale: 0.96,
filter: "blur(8px)",
transition: { duration: 0.18, ease: EASE_OUT },
}
}
transition={STACK_SPRING}
drag={canDismiss && !reduce ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
onDragEnd={(_, info) => {
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 }}
>
<div
className={cn(
"relative overflow-hidden rounded-2xl border border-border bg-card/95 p-3 shadow-2xl backdrop-blur-xl",
classNames?.surface,
)}
>
{renderToast ? (
renderToast(toast)
) : (
<div className="flex items-start gap-3">
<motion.span
layout
className={cn(
"mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
STATUS_CLASS[status],
classNames?.iconWrap,
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={status}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 8, scale: 0.8, filter: "blur(6px)" }
}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -8, scale: 0.9, filter: "blur(6px)" }
}
transition={CONTENT_TRANSITION}
className="inline-flex"
>
{status === "loading" ? (
<span className="inline-flex animate-spin">{iconNode}</span>
) : (
iconNode
)}
</motion.span>
</AnimatePresence>
</motion.span>
{/* layout="position" keeps the copy from stretching while the item's
height morphs between phases (e.g. a promise toast settling). */}
<motion.div layout="position" className={cn("min-w-0 flex-1", classNames?.content)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={`${toast.id}-${status}-${String(toast.title)}`}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 8, filter: "blur(6px)" }
}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -8, filter: "blur(6px)" }
}
transition={CONTENT_TRANSITION}
>
<p
className={cn(
"truncate text-sm font-medium leading-5 text-foreground",
classNames?.title,
)}
>
{toast.title}
</p>
{toast.description ? (
<p
className={cn(
"mt-0.5 line-clamp-2 text-xs leading-4 text-muted-foreground",
classNames?.description,
)}
>
{toast.description}
</p>
) : null}
</motion.div>
</AnimatePresence>
{toast.action ? (
<button
type="button"
onClick={() => toast.action?.onClick(toast)}
className={cn(
"mt-2 inline-flex h-7 items-center rounded-full bg-primary/[0.06] px-3 text-xs font-medium text-foreground transition-colors hover:bg-primary/[0.1]",
classNames?.action,
)}
>
{toast.action.label}
</button>
) : null}
</motion.div>
{canDismiss ? (
<button
type="button"
onClick={() => onDismiss?.(toast.id)}
aria-label="Dismiss toast"
className={cn(
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-primary/[0.06] hover:text-foreground",
classNames?.close,
)}
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
)}
</div>
</motion.li>
);
});
API Reference
useAnimatedToastStack
initialToasts?ToastInput[][]defaultDuration?number4200limit?number—AnimatedToastStack
toastsAnimatedToast[]—onDismiss?((id: string) => void)—position?"top-left" | "top-right" | "bottom-left" | "bottom-right" | "top-center" | "bottom-center"bottom-rightplacement?"static" | "fixed" | "absolute"—fixed?booleanfalseportal?boolean—portalRoot?Element | null—maxVisible?number4className?string—classNames?ToastClassNames—icons?Partial<Record<ToastStatus, ReactNode>>—renderToast?((toast: AnimatedToast) => ReactNode)—Updated