Animated Toast Stack
Stacked toasts with status morphs, swipe dismissal, actions and layout-aware motion.
Preview
Open a real toast
Toasts render fixed on the screen. Change position to open a toast from that edge.
TSXcomponents/previews/motion/animated-toast-stack.preview.tsx
"use client";
import { Check, LoaderCircle, Sparkles, X } from "lucide-react";
import { useState } from "react";
import {
AnimatedToastStack,
type ToastInput,
type ToastPosition,
useAnimatedToastStack,
} from "@/components/motion/animated-toast-stack";
import { cn } from "@/lib/utils";
const POSITIONS: ToastPosition[] = [
"top-left",
"top-center",
"top-right",
"bottom-left",
"bottom-center",
"bottom-right",
];
const EXAMPLES: Array<ToastInput & { label: string }> = [
{
label: "Promise",
status: "loading",
title: "Publishing component",
description: "Bundling source, preview, and registry metadata.",
duration: 0,
},
{
label: "Success",
status: "success",
title: "Component published",
description: "Registry endpoint and raw source are available.",
},
{
label: "Error",
status: "error",
title: "Snapshot failed",
description: "Retry after the browser target settles.",
},
];
export function AnimatedToastStackPreview() {
const [position, setPosition] = useState<ToastPosition>("bottom-right");
const {
toasts,
showToast,
updateToast,
dismissToast,
clearToasts,
} = useAnimatedToastStack({
defaultDuration: 3600,
limit: 5,
});
const openToast = (input: ToastInput) => {
const id = showToast(input);
if (input.status === "loading") {
window.setTimeout(() => {
updateToast(id, {
status: "success",
title: "Publish complete",
description: "Toast updated in-place from loading to success.",
duration: 3200,
});
}, 1800);
}
};
const moveStack = (nextPosition: ToastPosition) => {
setPosition(nextPosition);
showToast({
status: "info",
title: "Position changed",
description: `New toasts open from ${nextPosition}.`,
});
};
return (
<div className="flex min-h-72 w-full flex-col items-center justify-center gap-6">
<AnimatedToastStack
toasts={toasts}
onDismiss={dismissToast}
position={position}
placement="fixed"
maxVisible={4}
classNames={{
surface: "bg-card/95",
}}
icons={{
neutral: <Sparkles className="h-3.5 w-3.5" />,
}}
/>
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm font-medium text-foreground">Open a real toast</p>
<p className="max-w-sm text-xs leading-5 text-muted-foreground">
Toasts render fixed on the screen. Change position to open a toast from that edge.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
{EXAMPLES.map((example) => {
return (
<button
key={example.label}
type="button"
onClick={() => openToast(example)}
className="inline-flex h-9 items-center gap-2 rounded-full border border-border bg-card px-4 text-xs font-medium text-foreground transition-colors press hover:border-(--color-border-strong)"
>
{example.status === "loading" ? (
<LoaderCircle className="h-3.5 w-3.5" />
) : example.status === "success" ? (
<Check className="h-3.5 w-3.5" />
) : (
<X className="h-3.5 w-3.5" />
)}
{example.label}
</button>
);
})}
<button
type="button"
onClick={clearToasts}
className="inline-flex h-9 items-center rounded-full px-4 text-xs font-medium text-muted-foreground press hover:bg-foreground/[0.06] hover:text-foreground"
>
Clear
</button>
</div>
<div className="flex flex-wrap items-center justify-center gap-1.5">
{POSITIONS.map((positionOption) => (
<button
key={positionOption}
type="button"
onClick={() => moveStack(positionOption)}
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors press",
position === positionOption
? "bg-foreground text-background"
: "bg-foreground/[0.04] text-muted-foreground hover:bg-foreground/[0.08] hover:text-foreground",
)}
>
{positionOption}
</button>
))}
</div>
</div>
);
}
TSXcomponents/motion/animated-toast-stack.tsx
"use client";
// beui.dev/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 {
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;
};
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(),
};
}
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,
),
);
}, []);
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,
dismissToast,
clearToasts,
setToasts,
}),
[clearToasts, dismissToast, 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>
<div 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}
</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>
);
});
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/animated-toast-stack
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/animated-toast-stack.tsx
"use client";
// beui.dev/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 {
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;
};
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(),
};
}
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,
),
);
}, []);
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,
dismissToast,
clearToasts,
setToasts,
}),
[clearToasts, dismissToast, 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>
<div 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}
</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?{}[]defaultDuration?number4200limit?number—AnimatedToastStack
toasts{}—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?any—maxVisible?number4className?string—classNames?ToastClassNames—icons?any—renderToast?((toast: AnimatedToast) => ReactNode)—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated