Component
Stackable Toast
The Animated Carousel Component offers a visually engaging and dynamic way to showcase images or content in a rotating carousel format. Perfect for portfolios, galleries, or product displays, this component provides smooth transitions and rich interactivity, making it a standout feature on any website.
Features
- ▸Animated progress bar — a thin bar at the bottom of each toast visually counts down the time before auto-dismissal, so users always know how long they have to act.
- ▸Swipe to dismiss — toasts can be swiped left or right to dismiss them, with the card flying off in the swipe direction for a satisfying, mobile-friendly interaction.
- ▸Stacked queue — multiple toasts stack with a subtle scale and fade effect behind the active one, keeping the UI uncluttered while still hinting that more notifications are queued
- ▸Flexible positioning — toasts can be anchored to any corner or the center of the top/bottom edge of the screen, making it easy to fit any layout without overlap.
- ▸Action button support — each toast can carry an inline action button, letting users respond to a notification (undo, retry, view, etc.) without leaving their current context.
Stackable Toast
Click any trigger to spawn a toast. Stack several and watch the physics play out.
Installation
01.Install Dependencies
npm install motion class-variance-authority @tabler/icons-reactSource Code
"use client";
import * as React from "react";
import { motion, AnimatePresence } from "motion/react";
import {
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
IconLoader2,
IconX,
} from "@tabler/icons-react";
import { cva } from "class-variance-authority";
import { cn } from "@/utils/cn";
// --- Configuration & Styles ---
const toastVariants = cva(
"pointer-events-auto group relative flex items-start gap-3 w-full cursor-grab overflow-hidden rounded-xl bg-zinc-900/95 p-3.5 pr-2 ring-1 backdrop-blur-xl shadow-2xl shadow-black/40 active:cursor-grabbing",
{
variants: {
variant: {
default: "ring-white/10",
success: "ring-emerald-500/30",
error: "ring-rose-500/30",
info: "ring-sky-500/30",
loading: "ring-yellow-500/30",
},
},
defaultVariants: {
variant: "default",
},
}
);
const progressVariants = cva("absolute bottom-0 left-0 h-0.5 w-full origin-left", {
variants: {
variant: {
default: "bg-white/30",
success: "bg-emerald-400",
error: "bg-rose-400",
info: "bg-sky-400",
loading: "bg-yellow-400",
},
},
defaultVariants: { variant: "default" },
});
const defaultIcons = {
default: null,
success: <IconCircleCheckFilled className="text-emerald-400" size={18} />,
error: <IconCircleXFilled className="text-rose-400" size={18} />,
info: <IconInfoCircleFilled className="text-sky-400" size={18} />,
loading: <IconLoader2 className="animate-spin text-yellow-400" size={18} />,
};
// --- Store ---
class ToastStore {
constructor() {
this.toasts = [];
this.listeners = new Set();
}
subscribe = (listener) => {
this.listeners.add(listener);
listener([...this.toasts]);
return () => { this.listeners.delete(listener); };
};
emit = () => {
this.listeners.forEach((listener) => listener([...this.toasts]));
};
add = (toast) => {
const id = toast.id ?? Math.random().toString(36).slice(2);
const newToast = { ...toast, id, createdAt: Date.now() };
const exists = this.toasts.findIndex((t) => t.id === id);
if (exists >= 0) {
this.toasts[exists] = { ...this.toasts[exists], ...newToast };
} else {
this.toasts = [newToast, ...this.toasts];
}
this.emit();
return id;
};
dismiss = (id) => {
if (id) {
this.toasts = this.toasts.filter((t) => t.id !== id);
} else {
this.toasts = [];
}
this.emit();
};
}
const store = new ToastStore();
// --- API ---
const createToast = (title, opts) => {
return store.add({ title, ...opts });
};
export const toast = Object.assign(createToast, {
success: (title, opts) =>
createToast(title, { ...opts, variant: "success" }),
error: (title, opts) =>
createToast(title, { ...opts, variant: "error" }),
info: (title, opts) =>
createToast(title, { ...opts, variant: "info" }),
loading: (title, opts) =>
createToast(title, { ...opts, variant: "loading", duration: Infinity }),
dismiss: (id) => store.dismiss(id),
promise: async (promise, msgs) => {
const id = createToast(msgs.loading, { variant: "loading", duration: Infinity });
try {
const data = await promise;
createToast(typeof msgs.success === "function" ? msgs.success(data) : msgs.success, {
id,
variant: "success",
});
return data;
} catch (err) {
createToast(typeof msgs.error === "function" ? msgs.error(err) : msgs.error, {
id,
variant: "error",
});
throw err;
}
},
});
// --- Components ---
export function ToastProvider({
position = "bottom-right",
max = 3,
stackPhysics = true,
swipeThreshold = 80,
}) {
const [toasts, setToasts] = React.useState([]);
React.useEffect(() => store.subscribe(setToasts), []);
React.useEffect(() => {
const timers = toasts.map((t) => {
if (t.duration === Infinity) return null;
const duration = t.duration ?? (t.variant === "error" ? 6000 : 4500);
const remaining = duration - (Date.now() - t.createdAt);
return setTimeout(() => store.dismiss(t.id), Math.max(0, remaining));
});
return () => timers.forEach((timer) => timer && clearTimeout(timer));
}, [toasts]);
const isTop = position.startsWith("top");
const alignment = position.endsWith("center")
? "items-center left-1/2 -translate-x-1/2"
: position.endsWith("right")
? "items-end right-0"
: "items-start left-0";
return (
<div
className={cn(
"pointer-events-none fixed z-[9999] flex w-full max-w-md flex-col gap-2 p-4",
isTop ? "top-0" : "bottom-0",
alignment
)}
>
<div className="relative w-full">
<AnimatePresence initial={false}>
{toasts.slice(0, max).map((t, index) => (
<ToastCard
key={t.id}
toast={t}
index={index}
total={Math.min(toasts.length, max)}
isTop={isTop}
stackPhysics={stackPhysics}
swipeThreshold={swipeThreshold}
/>
))}
</AnimatePresence>
</div>
</div>
);
}
function ToastCard({ toast: t, index, total, isTop, stackPhysics, swipeThreshold }) {
const [dragX, setDragX] = React.useState(0);
const variant = t.variant ?? "default";
// Stacking physics calculations
const yOffset = stackPhysics ? index * (isTop ? 8 : -8) : 0;
const scale = stackPhysics ? 1 - index * 0.05 : 1;
const opacity = stackPhysics ? 1 - index * 0.25 : 1;
return (
<motion.div
layout
drag="x"
dragSnapToOrigin
onDrag={(_, info) => setDragX(info.offset.x)}
onDragEnd={(_, info) => {
if (Math.abs(info.offset.x) > swipeThreshold) store.dismiss(t.id);
setDragX(0);
}}
initial={{ opacity: 0, y: isTop ? -40 : 40, scale: 0.92 }}
animate={{ opacity, y: yOffset, scale, zIndex: total - index }}
exit={{
opacity: 0,
x: dragX !== 0 ? Math.sign(dragX) * 400 : 0,
y: dragX !== 0 ? 0 : isTop ? -20 : 20,
scale: 0.92,
transition: { duration: 0.2 },
}}
transition={{ type: "spring", stiffness: 380, damping: 32, mass: 0.7 }}
style={{ position: index === 0 ? "relative" : "absolute", top: 0, left: 0, right: 0 }}
className={toastVariants({ variant })}
>
{/* Icon */}
<div className="mt-0.5 shrink-0">{t.icon ?? defaultIcons[variant]}</div>
{/* Content */}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white">{t.title}</p>
{t.description && <p className="mt-0.5 text-xs text-zinc-400">{t.description}</p>}
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
{t.action && (
<button
onClick={() => {
t.action?.onClick();
store.dismiss(t.id);
}}
className="rounded-md bg-white/10 px-2.5 py-1 text-xs font-medium text-white hover:bg-white/15 transition-colors"
>
{t.action.label}
</button>
)}
<button
onClick={() => store.dismiss(t.id)}
className="rounded-md p-1 text-zinc-500 opacity-0 transition group-hover:opacity-100 hover:bg-white/5 hover:text-white"
aria-label="Dismiss"
>
<IconX size={14} />
</button>
</div>
{/* Progress Bar */}
{t.duration !== Infinity && index === 0 && (
<motion.div
key={t.id + t.createdAt}
initial={{ scaleX: 1 }}
animate={{ scaleX: 0 }}
transition={{ duration: (t.duration ?? (variant === "error" ? 6000 : 4500)) / 1000, ease: "linear" }}
className={progressVariants({ variant })}
/>
)}
</motion.div>
);
}