Stacked Toast
Original · freestacked toast queue
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/toast-stacked.json"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ToastType = "success" | "error" | "warning" | "info";
type Position = "top-left" | "top-right" | "bottom-left" | "bottom-right";
interface Preset {
title: string;
description: string;
action?: string;
}
interface ToastItem {
id: string;
type: ToastType;
title: string;
description: string;
action?: string;
duration: number;
}
const MAX_TOASTS = 4;
const COLLAPSED_VISIBLE = 3;
const PEEK = 16;
const GAP = 12;
const DURATION = 5400;
const FALLBACK_HEIGHT = 88;
const PRESETS: Record<ToastType, Preset[]> = {
success: [
{
title: "Payment received",
description: "Invoice INV-2043 for $2,400 was paid by Meridian Labs. A receipt is on its way.",
},
{
title: "Deployment live",
description: "main → production finished in 47s. 3 files changed, 0 errors.",
action: "View log",
},
{
title: "Backup complete",
description: "Nightly snapshot saved to us-east-1 — 18.4 GB across 6 volumes.",
},
],
error: [
{
title: "Upload failed",
description: "brand-assets.zip couldn't be uploaded. Check your connection and try again.",
action: "Retry",
},
{
title: "Payment declined",
description: "The card ending 4291 was declined by the issuer. Try a different method.",
action: "Update card",
},
],
warning: [
{
title: "Storage almost full",
description: "You've used 92% of your 50 GB plan. Archive old projects to free up space.",
action: "Manage",
},
{
title: "Session expiring",
description: "You'll be signed out in 5 minutes due to inactivity.",
action: "Stay signed in",
},
],
info: [
{
title: "New comment from Priya",
description: "“Can we ship the dark-mode variant this week?” on Checkout redesign.",
action: "Reply",
},
{
title: "3 teammates joined",
description: "Ravi, Anna and Ken now have access to the Growth workspace.",
},
],
};
const THEME: Record<
ToastType,
{ chip: string; bar: string; label: string; dot: string }
> = {
success: {
chip: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400",
bar: "bg-emerald-500",
label: "Success",
dot: "bg-emerald-500",
},
error: {
chip: "bg-rose-50 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400",
bar: "bg-rose-500",
label: "Error",
dot: "bg-rose-500",
},
warning: {
chip: "bg-amber-50 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400",
bar: "bg-amber-500",
label: "Warning",
dot: "bg-amber-500",
},
info: {
chip: "bg-sky-50 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400",
bar: "bg-sky-500",
label: "Info",
dot: "bg-sky-500",
},
};
function TypeIcon({ type }: { type: ToastType }) {
const common = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
className: "h-5 w-5",
};
if (type === "success") {
return (
<svg {...common}>
<path d="M21.5 12a9.5 9.5 0 1 1-4.2-7.9" />
<path d="m8.5 12 2.7 2.7L22 4.4" />
</svg>
);
}
if (type === "error") {
return (
<svg {...common}>
<circle cx="12" cy="12" r="9.25" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
);
}
if (type === "warning") {
return (
<svg {...common}>
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
<path d="M12 9.5v4" />
<path d="M12 17.2h.01" />
</svg>
);
}
return (
<svg {...common}>
<circle cx="12" cy="12" r="9.25" />
<path d="M12 16.5v-4.5" />
<path d="M12 8h.01" />
</svg>
);
}
interface ToastCardProps {
toast: ToastItem;
index: number;
total: number;
expanded: boolean;
paused: boolean;
autoDismiss: boolean;
position: Position;
offsetBefore: number;
reduced: boolean;
onDismiss: (id: string) => void;
onAction: (toast: ToastItem) => void;
reportHeight: (id: string, h: number) => void;
}
function ToastCard({
toast,
index,
total,
expanded,
paused,
autoDismiss,
position,
offsetBefore,
reduced,
onDismiss,
onAction,
reportHeight,
}: ToastCardProps) {
const cardRef = useRef<HTMLDivElement | null>(null);
const remainingRef = useRef(toast.duration);
const theme = THEME[toast.type];
const isTop = position.startsWith("top");
const isRight = position.endsWith("right");
const dir = isTop ? 1 : -1;
const enterX = isRight ? 34 : -34;
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const send = () => reportHeight(toast.id, el.offsetHeight);
const ro = new ResizeObserver(send);
ro.observe(el);
send();
return () => ro.disconnect();
}, [toast.id, reportHeight]);
useEffect(() => {
if (!autoDismiss || paused) return;
const start = Date.now();
const timer = window.setTimeout(() => onDismiss(toast.id), remainingRef.current);
return () => {
remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - start));
window.clearTimeout(timer);
};
}, [autoDismiss, paused, onDismiss, toast.id]);
const clamped = Math.min(index, COLLAPSED_VISIBLE - 1);
const target = expanded
? { x: 0, y: dir * offsetBefore, scale: 1, opacity: 1 }
: {
x: 0,
y: dir * clamped * PEEK,
scale: 1 - clamped * 0.05,
opacity: index < COLLAPSED_VISIBLE ? 1 : 0,
};
const interactive = expanded || index === 0;
const spring = reduced
? { duration: 0 }
: { type: "spring" as const, stiffness: 380, damping: 38, mass: 0.9 };
return (
<motion.li
className={`absolute inset-x-0 ${isTop ? "top-0" : "bottom-0"} list-none`}
style={{ zIndex: total - index, pointerEvents: interactive ? "auto" : "none" }}
initial={reduced ? false : { opacity: 0, x: enterX, scale: 0.85 }}
animate={target}
exit={reduced ? { opacity: 0 } : { opacity: 0, x: enterX, scale: 0.85, transition: { duration: 0.2 } }}
transition={spring}
>
<div
ref={cardRef}
role={toast.type === "error" ? "alert" : "status"}
className="relative overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-lg shadow-slate-900/[0.06] dark:border-slate-700/70 dark:bg-slate-900 dark:shadow-black/40"
>
<div className="flex gap-3 p-3.5">
<span
className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${theme.chip}`}
aria-hidden="true"
>
<TypeIcon type={toast.type} />
</span>
<div className="min-w-0 flex-1 pr-5">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{toast.title}
</p>
<p className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
{toast.description}
</p>
{toast.action ? (
<button
type="button"
onClick={() => onAction(toast)}
className="mt-2 inline-flex items-center gap-1 rounded-md border border-slate-200 px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
{toast.action}
</button>
) : null}
</div>
</div>
<button
type="button"
onClick={() => onDismiss(toast.id)}
aria-label={`Dismiss notification: ${toast.title}`}
className="absolute right-2.5 top-2.5 grid h-6 w-6 place-items-center rounded-md text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-3.5 w-3.5"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
{autoDismiss ? (
<span
aria-hidden="true"
className={`tqStack-progress absolute inset-x-0 bottom-0 h-[3px] origin-left ${theme.bar}`}
style={{
animation: `tqStack-shrink ${toast.duration}ms linear forwards`,
animationPlayState: paused ? "paused" : "running",
}}
/>
) : null}
</div>
</motion.li>
);
}
export default function ToastStacked() {
const reduced = useReducedMotion() ?? false;
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const [pinnedExpanded, setPinnedExpanded] = useState(false);
const [hovered, setHovered] = useState(false);
const [focusWithin, setFocusWithin] = useState(false);
const [position, setPosition] = useState<Position>("bottom-right");
const [autoDismiss, setAutoDismiss] = useState(true);
const idRef = useRef(0);
const counters = useRef<Record<ToastType, number>>({
success: 0,
error: 0,
warning: 0,
info: 0,
});
const nid = () => `tqs-${++idRef.current}`;
const build = useCallback((type: ToastType): ToastItem => {
const list = PRESETS[type];
const preset = list[counters.current[type]++ % list.length];
return { id: nid(), type, duration: DURATION, ...preset };
}, []);
const push = useCallback(
(type: ToastType) => {
setToasts((prev) => [build(type), ...prev].slice(0, MAX_TOASTS));
},
[build],
);
const burst = useCallback(() => {
const order: ToastType[] = ["info", "warning", "success"];
setToasts((prev) => {
const next = order.map((t) => build(t));
return [...next.reverse(), ...prev].slice(0, MAX_TOASTS);
});
}, [build]);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const handleAction = useCallback((t: ToastItem) => {
setToasts((prev) => {
const filtered = prev.filter((x) => x.id !== t.id);
const confirm: ToastItem = {
id: `tqs-c-${Date.now()}`,
type: "success",
title: "On it",
description: `“${t.action}” — we've queued that action for you.`,
duration: DURATION,
};
return [confirm, ...filtered].slice(0, MAX_TOASTS);
});
}, []);
const clearAll = useCallback(() => setToasts([]), []);
const reportHeight = useCallback((id: string, h: number) => {
setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
}, []);
const expanded = pinnedExpanded || hovered || focusWithin;
const count = toasts.length;
const offsets = useMemo(() => {
const out: number[] = [];
let acc = 0;
for (let i = 0; i < toasts.length; i++) {
out[i] = acc;
acc += (heights[toasts[i].id] ?? FALLBACK_HEIGHT) + GAP;
}
return out;
}, [toasts, heights]);
const isTop = position.startsWith("top");
const isRight = position.endsWith("right");
const anchorClass = `${isTop ? "top-4 sm:top-6" : "bottom-4 sm:bottom-6"} ${
isRight ? "right-4 sm:right-6" : "left-4 sm:left-6"
}`;
const triggers: { type: ToastType; label: string }[] = [
{ type: "success", label: "Success" },
{ type: "error", label: "Error" },
{ type: "warning", label: "Warning" },
{ type: "info", label: "Info" },
];
const positions: { value: Position; label: string }[] = [
{ value: "top-left", label: "Top left" },
{ value: "top-right", label: "Top right" },
{ value: "bottom-left", label: "Bottom left" },
{ value: "bottom-right", label: "Bottom right" },
];
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
<div className="relative mx-auto max-w-5xl">
<header className="mb-10 max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Toasts
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
Stacked notification queue
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-500 dark:text-slate-400">
Notifications collapse into a tidy stack and fan out on hover or focus. Each toast counts
down, pauses while you read, and can be dismissed or acted on — all keyboard accessible.
</p>
</header>
<div className="grid gap-6 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
{/* Control panel */}
<div className="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Trigger notifications
</h3>
<span className="rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium tabular-nums text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{count}/{MAX_TOASTS}
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{triggers.map((t) => (
<button
key={t.type}
type="button"
onClick={() => push(t.type)}
className="group flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:border-slate-300 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<span className={`grid h-6 w-6 place-items-center rounded-md ${THEME[t.type].chip}`}>
<TypeIcon type={t.type} />
</span>
{t.label}
</button>
))}
</div>
<button
type="button"
onClick={burst}
className="mt-2 flex w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 px-3 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-4 w-4"
>
<path d="M13 2 4.5 12.5H11l-1 9.5 8.5-10.5H12l1-9.5Z" />
</svg>
Simulate a burst
</button>
<div className="my-5 h-px bg-slate-200 dark:bg-slate-800" />
<fieldset>
<legend className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Anchor position
</legend>
<div className="mt-2.5 grid grid-cols-2 gap-2">
{positions.map((p) => (
<label key={p.value} className="cursor-pointer">
<input
type="radio"
name="tqStack-position"
value={p.value}
checked={position === p.value}
onChange={() => setPosition(p.value)}
className="peer sr-only"
/>
<span className="flex items-center justify-center rounded-lg border border-slate-200 px-2 py-2 text-xs font-medium text-slate-600 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-checked:text-indigo-700 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:peer-checked:border-indigo-500 dark:peer-checked:bg-indigo-500/15 dark:peer-checked:text-indigo-300 dark:peer-focus-visible:ring-offset-slate-900">
{p.label}
</span>
</label>
))}
</div>
</fieldset>
<div className="mt-5 flex items-center justify-between gap-3">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
Auto-dismiss
</span>
<button
type="button"
role="switch"
aria-checked={autoDismiss}
aria-label="Toggle auto-dismiss"
onClick={() => setAutoDismiss((v) => !v)}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
autoDismiss ? "bg-indigo-600" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
autoDismiss ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
<div className="mt-5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setPinnedExpanded((v) => !v)}
aria-pressed={pinnedExpanded}
disabled={count === 0}
className="rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-45 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
{pinnedExpanded ? "Collapse" : "Expand"}
</button>
<button
type="button"
onClick={clearAll}
disabled={count === 0}
className="rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-45 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Clear all
</button>
</div>
</div>
{/* Stage */}
<div
aria-label="Notification preview area"
className="relative min-h-[440px] overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 sm:min-h-[500px] dark:border-slate-800 dark:bg-slate-900/40"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocusCapture={() => setFocusWithin(true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);
}}
onKeyDown={(e) => {
if (e.key === "Escape" && toasts.length > 0) dismiss(toasts[0].id);
}}
>
<div
className="pointer-events-none absolute inset-0 bg-[radial-gradient(rgba(148,163,184,0.18)_1px,transparent_1px)] [background-size:20px_20px]"
aria-hidden="true"
/>
<span
className="tqStack-orb pointer-events-none absolute -left-16 top-8 h-52 w-52 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
aria-hidden="true"
/>
<span
className="tqStack-orb pointer-events-none absolute -bottom-16 right-4 h-56 w-56 rounded-full bg-emerald-400/20 blur-3xl dark:bg-emerald-500/10"
aria-hidden="true"
style={{ animationDelay: "-4s" }}
/>
<div className="relative flex h-full items-center justify-center px-6 py-14">
{count === 0 ? (
<div className="max-w-xs text-center">
<span className="mx-auto grid h-12 w-12 place-items-center rounded-full border border-slate-200 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-500">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-6 w-6"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.9 1.9 0 0 0 3.4 0" />
</svg>
</span>
<p className="mt-4 text-sm font-medium text-slate-600 dark:text-slate-300">
Your queue is clear
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Trigger a notification from the panel to watch the stack build.
</p>
</div>
) : (
<span className="text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-600">
{expanded ? "Expanded" : "Hover or focus to expand"}
</span>
)}
</div>
<ol
aria-label="Notifications"
aria-live="polite"
aria-relevant="additions text"
className={`pointer-events-none absolute z-10 w-[min(340px,calc(100%-2rem))] ${anchorClass}`}
>
<AnimatePresence initial={false}>
{toasts.map((t, i) => (
<ToastCard
key={t.id}
toast={t}
index={i}
total={toasts.length}
expanded={expanded}
paused={expanded}
autoDismiss={autoDismiss}
position={position}
offsetBefore={offsets[i] ?? 0}
reduced={reduced}
onDismiss={dismiss}
onAction={handleAction}
reportHeight={reportHeight}
/>
))}
</AnimatePresence>
</ol>
</div>
</div>
<p className="mt-4 text-xs text-slate-400 dark:text-slate-500">
Newest on top · up to {MAX_TOASTS} kept · press Esc over the stage to dismiss the newest.
</p>
</div>
<style>{`
@keyframes tqStack-shrink {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
@keyframes tqStack-drift {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(8%, -6%, 0); }
}
.tqStack-orb {
animation: tqStack-drift 14s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.tqStack-progress,
.tqStack-orb {
animation: none !important;
}
}
`}</style>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Toast
Originalbasic toast notifications

Variants Toast
Originalsuccess/info/warning/error toasts
Icon Toast
Originaltoasts with icon and title

Progress Toast
Originaltoast with auto-dismiss progress

Action Toast
Originaltoast with an undo action

Slide In Toast
Originalslide-in corner toasts

Promise Toast
Originalloading-to-result promise toast

Glass Toast
Originalglassmorphic toasts

Position Toast
Originaltoast position picker demo

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

