Toast Stack Alert
Original · freeA stack of dismissible toasts in a corner.
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/alert-toast-stack.json"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ToastVariant = "success" | "error" | "warning" | "info";
interface Toast {
id: number;
variant: ToastVariant;
title: string;
message: string;
action?: { label: string; onAction: () => void };
duration: number;
}
interface VariantStyle {
ring: string;
iconWrap: string;
iconColor: string;
bar: string;
accent: string;
icon: ReactNode;
}
const VARIANTS: Record<ToastVariant, VariantStyle> = {
success: {
ring: "ring-emerald-500/20 dark:ring-emerald-400/20",
iconWrap: "bg-emerald-50 dark:bg-emerald-500/10",
iconColor: "text-emerald-600 dark:text-emerald-400",
bar: "bg-emerald-500 dark:bg-emerald-400",
accent: "text-emerald-700 dark:text-emerald-300",
icon: (
<svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
<path
d="M6 10.5l2.5 2.5L14.5 7"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
</svg>
),
},
error: {
ring: "ring-rose-500/20 dark:ring-rose-400/20",
iconWrap: "bg-rose-50 dark:bg-rose-500/10",
iconColor: "text-rose-600 dark:text-rose-400",
bar: "bg-rose-500 dark:bg-rose-400",
accent: "text-rose-700 dark:text-rose-300",
icon: (
<svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
<path d="M10 6v5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
<circle cx="10" cy="13.75" r="1" fill="currentColor" />
<circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
</svg>
),
},
warning: {
ring: "ring-amber-500/20 dark:ring-amber-400/20",
iconWrap: "bg-amber-50 dark:bg-amber-500/10",
iconColor: "text-amber-600 dark:text-amber-400",
bar: "bg-amber-500 dark:bg-amber-400",
accent: "text-amber-700 dark:text-amber-300",
icon: (
<svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
<path
d="M10 2.75L18 16.5H2L10 2.75z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
opacity="0.4"
/>
<path d="M10 8v3.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
<circle cx="10" cy="14" r="1" fill="currentColor" />
</svg>
),
},
info: {
ring: "ring-sky-500/20 dark:ring-sky-400/20",
iconWrap: "bg-sky-50 dark:bg-sky-500/10",
iconColor: "text-sky-600 dark:text-sky-400",
bar: "bg-sky-500 dark:bg-sky-400",
accent: "text-sky-700 dark:text-sky-300",
icon: (
<svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
<path d="M10 9.25v4.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
<circle cx="10" cy="6.5" r="1" fill="currentColor" />
<circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
</svg>
),
},
};
const PRESETS: Omit<Toast, "id">[] = [
{
variant: "success",
title: "Payment received",
message: "Invoice #4021 for $2,400 was settled by Northwind Ltd.",
duration: 5000,
action: { label: "View receipt", onAction: () => {} },
},
{
variant: "error",
title: "Deploy failed",
message: "Build step exited with code 1 — check the CI logs for details.",
duration: 6000,
action: { label: "Retry", onAction: () => {} },
},
{
variant: "warning",
title: "Storage almost full",
message: "You've used 92% of your 50 GB plan. Uploads may soon be paused.",
duration: 6000,
action: { label: "Upgrade", onAction: () => {} },
},
{
variant: "info",
title: "New comment",
message: "Priya mentioned you on the “Q3 roadmap” document.",
duration: 5000,
action: { label: "Open", onAction: () => {} },
},
];
function ToastCard({
toast,
onDismiss,
reduced,
}: {
toast: Toast;
onDismiss: (id: number) => void;
reduced: boolean;
}) {
const v = VARIANTS[toast.variant];
const [paused, setPaused] = useState(false);
const remainingRef = useRef(toast.duration);
const startRef = useRef<number>(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clear = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
useEffect(() => {
if (paused) {
clear();
remainingRef.current -= Date.now() - startRef.current;
return;
}
startRef.current = Date.now();
timerRef.current = setTimeout(() => onDismiss(toast.id), remainingRef.current);
return clear;
}, [paused, clear, onDismiss, toast.id]);
return (
<motion.li
layout
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.96 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, x: 60, scale: 0.9 }}
transition={{ type: "spring", stiffness: 460, damping: 34, mass: 0.7 }}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={() => setPaused(false)}
role="status"
aria-live={toast.variant === "error" ? "assertive" : "polite"}
className="pointer-events-auto relative w-full overflow-hidden rounded-2xl bg-white text-slate-900 shadow-lg shadow-slate-900/10 ring-1 ring-inset ring-slate-950/5 backdrop-blur dark:bg-slate-900 dark:text-slate-100 dark:shadow-black/40 dark:ring-white/10"
>
<div className={`pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-inset ${v.ring}`} />
<div className="flex items-start gap-3 p-4">
<span
className={`mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-full ${v.iconWrap} ${v.iconColor}`}
>
{v.icon}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold leading-5">{toast.title}</p>
<p className="mt-0.5 text-sm leading-5 text-slate-600 dark:text-slate-400">{toast.message}</p>
{toast.action && (
<button
type="button"
onClick={() => {
toast.action?.onAction();
onDismiss(toast.id);
}}
className={`mt-2 rounded-md text-sm font-semibold underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${v.accent} focus-visible:ring-slate-400 dark:focus-visible:ring-slate-500`}
>
{toast.action.label}
</button>
)}
</div>
<button
type="button"
onClick={() => onDismiss(toast.id)}
aria-label={`Dismiss ${toast.title}`}
className="-mr-1 -mt-1 grid h-7 w-7 shrink-0 place-items-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-300 dark:focus-visible:ring-slate-500"
>
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M5 5l10 10M15 5L5 15"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{!reduced && (
<div className="absolute inset-x-0 bottom-0 h-1 bg-slate-100 dark:bg-slate-800">
<span
key={paused ? "paused" : "running"}
className={`atk-bar block h-full origin-left ${v.bar}`}
style={{
animationName: paused ? "none" : "atk-shrink",
animationDuration: `${remainingRef.current}ms`,
}}
/>
</div>
)}
</motion.li>
);
}
export default function AlertToastStack() {
const reduced = useReducedMotion() ?? false;
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const cursor = useRef(0);
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const push = useCallback((preset: Omit<Toast, "id">) => {
const id = nextId.current++;
setToasts((prev) => {
const next = [{ ...preset, id }, ...prev];
return next.slice(0, 4);
});
}, []);
const pushNext = useCallback(() => {
const preset = PRESETS[cursor.current % PRESETS.length];
cursor.current += 1;
push(preset);
}, [push]);
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 dark:bg-slate-950 sm:py-28">
<style>{`
@keyframes atk-shrink {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
.atk-bar {
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@media (prefers-reduced-motion: reduce) {
.atk-bar { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-2xl text-center">
<span className="inline-flex items-center rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/25">
Notifications
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
Toast stack
</h2>
<p className="mt-3 text-base leading-7 text-slate-600 dark:text-slate-400">
Fire a notification and watch it stack in the corner. Hover to pause, click the close icon
or an action to dismiss.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={pushNext}
className="rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-transform hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-white dark:text-slate-900 dark:focus-visible:ring-offset-slate-950"
>
Add toast
</button>
{(["success", "error", "warning", "info"] as const).map((variant) => {
const preset = PRESETS.find((p) => p.variant === variant)!;
const v = VARIANTS[variant];
return (
<button
key={variant}
type="button"
onClick={() => push(preset)}
className={`inline-flex items-center gap-1.5 rounded-xl bg-white px-4 py-2.5 text-sm font-semibold capitalize shadow-sm ring-1 ring-inset ring-slate-200 transition-transform hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-slate-900 dark:ring-slate-700 dark:focus-visible:ring-offset-slate-950 ${v.accent}`}
>
<span className={v.iconColor}>{v.icon}</span>
{variant}
</button>
);
})}
{toasts.length > 0 && (
<button
type="button"
onClick={() => setToasts([])}
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-500 transition-colors hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 dark:text-slate-400 dark:hover:text-slate-200"
>
Clear all
</button>
)}
</div>
</div>
<div
aria-live="polite"
aria-label="Notifications"
className="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex justify-center px-4 pb-4 sm:inset-x-auto sm:right-6 sm:bottom-6 sm:justify-end sm:px-0"
>
<ul className="flex w-full max-w-sm flex-col-reverse gap-3">
<AnimatePresence initial={false} mode="popLayout">
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} onDismiss={dismiss} reduced={reduced} />
))}
</AnimatePresence>
</ul>
</div>
</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 →
Soft Variants Alert
OriginalSoft-tinted alerts: success, info, warning and error, each with an icon.

Solid Variants Alert
OriginalSolid-colour alerts across the four states.

Outline Variants Alert
OriginalOutlined alerts across the four states.

Left Accent Alert
OriginalAlerts with a coloured left accent bar and icon.

Dismissible Alert
OriginalDismissible alerts with a working close button.

With Action Alert
OriginalAn alert with title, body and action buttons.
Icon Large Alert
OriginalAn alert led by a large circular icon.

Banner Top Alert
OriginalA full-width top announcement banner, dismissible.

Toast Slide Alert
OriginalA toast that slides in with an icon and auto-dismiss progress.

Progress Alert
OriginalAn alert with a countdown progress bar.

Gradient Alert
OriginalA gradient promo banner alert with a call to action.

Inline Form Alert
OriginalAn alert containing an inline email input and button.

