Action Toast
Original · freetoast with an undo action
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-action.json"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type FocusEvent,
} from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Transition,
} from "motion/react";
type InboxItem = {
id: string;
title: string;
meta: string;
unread: boolean;
};
type UndoToast = {
id: string;
item: InboxItem;
index: number;
duration: number;
remaining: number;
hover: boolean;
focus: boolean;
};
const DURATION = 6000;
const MAX_TOASTS = 3;
const INITIAL_ITEMS: InboxItem[] = [
{ id: "m-roadmap", title: "Q3 roadmap needs your sign-off", meta: "Priya Nair · 9:24 AM", unread: true },
{ id: "m-invoice", title: "Invoice #4021 is ready to approve", meta: "Billing · Yesterday", unread: true },
{ id: "m-figma", title: "3 new comments on “Onboarding revamp”", meta: "Figma · Yesterday", unread: false },
{ id: "m-standup", title: "Standup notes — July 15", meta: "Marcus Bell · Tue", unread: false },
{ id: "m-security", title: "New sign-in from Lisbon", meta: "Accounts · Tue", unread: false },
{ id: "m-lunch", title: "Lunch & learn: shipping faster", meta: "People Ops · Mon", unread: false },
];
function ArchiveIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="4.5" rx="1.2" />
<path d="M5 8.5V18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5" />
<path d="M10 12.5h4" />
</svg>
);
}
function UndoIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 9h11.5a4.5 4.5 0 1 1 0 9H9" />
<path d="M7.5 4.5 3.5 9l4 4.5" />
</svg>
);
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
);
}
function InboxIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 13h4l1.8 3h4.4L16 13h4" />
<path d="M4 13 6.4 5.4A2 2 0 0 1 8.3 4h7.4a2 2 0 0 1 1.9 1.4L20 13v4.5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
</svg>
);
}
function RefreshIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 11a8 8 0 1 0-1.7 6.1" />
<path d="M20 4v5h-5" />
</svg>
);
}
export default function ToastAction() {
const reduce = useReducedMotion();
const [items, setItems] = useState<InboxItem[]>(INITIAL_ITEMS);
const [toasts, setToasts] = useState<UndoToast[]>([]);
const itemsRef = useRef<InboxItem[]>(items);
const toastsRef = useRef<UndoToast[]>(toasts);
const seq = useRef(0);
useEffect(() => {
itemsRef.current = items;
}, [items]);
useEffect(() => {
toastsRef.current = toasts;
}, [toasts]);
const hasToasts = toasts.length > 0;
useEffect(() => {
if (!hasToasts) return;
let last = performance.now();
const id = window.setInterval(() => {
const now = performance.now();
const delta = now - last;
last = now;
setToasts((prev) => {
let changed = false;
const next: UndoToast[] = [];
for (const t of prev) {
if (t.hover || t.focus) {
next.push(t);
continue;
}
const remaining = t.remaining - delta;
if (remaining <= 0) {
changed = true;
continue;
}
changed = true;
next.push({ ...t, remaining });
}
return changed ? next : prev;
});
}, 90);
return () => window.clearInterval(id);
}, [hasToasts]);
const archive = useCallback((id: string) => {
const current = itemsRef.current;
const index = current.findIndex((x) => x.id === id);
if (index === -1) return;
const item = current[index];
setItems((prev) => prev.filter((x) => x.id !== id));
seq.current += 1;
const toastId = `ta-toast-${item.id}-${seq.current}`;
setToasts((prev) =>
[
{ id: toastId, item, index, duration: DURATION, remaining: DURATION, hover: false, focus: false },
...prev,
].slice(0, MAX_TOASTS),
);
}, []);
const undo = useCallback((toastId: string) => {
const t = toastsRef.current.find((x) => x.id === toastId);
if (!t) return;
setItems((prev) => {
const arr = prev.slice();
arr.splice(Math.min(t.index, arr.length), 0, t.item);
return arr;
});
setToasts((prev) => prev.filter((x) => x.id !== toastId));
}, []);
const dismiss = useCallback((toastId: string) => {
setToasts((prev) => prev.filter((x) => x.id !== toastId));
}, []);
const setPause = useCallback(
(toastId: string, key: "hover" | "focus", value: boolean) => {
setToasts((prev) => prev.map((t) => (t.id === toastId ? { ...t, [key]: value } : t)));
},
[],
);
const reset = useCallback(() => {
setToasts([]);
setItems(INITIAL_ITEMS);
}, []);
const handleBlur = useCallback(
(toastId: string, e: FocusEvent<HTMLLIElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setPause(toastId, "focus", false);
}
},
[setPause],
);
const archivedCount = INITIAL_ITEMS.length - items.length;
const canReset = items.length < INITIAL_ITEMS.length || toasts.length > 0;
const itemSpring: Transition = reduce
? { duration: 0.15 }
: { type: "spring", stiffness: 520, damping: 42 };
const toastSpring: Transition = reduce
? { duration: 0.16, ease: "easeOut" }
: { type: "spring", stiffness: 420, damping: 34, mass: 0.9 };
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 [background-image:radial-gradient(circle_at_18%_12%,rgba(99,102,241,0.10),transparent_45%),radial-gradient(circle_at_88%_82%,rgba(16,185,129,0.08),transparent_50%)]"
/>
<div className="relative mx-auto w-full max-w-2xl">
<header className="mb-8">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Reversible actions
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight sm:text-3xl">
Archive without the fear
</h2>
<p className="mt-2 max-w-lg text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Every archive fires a toast with a six-second window to undo. Hover or focus a
toast to pause its timer — mistakes should never be permanent by accident.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<div className="flex items-center gap-2">
<InboxIcon className="h-5 w-5 text-slate-400 dark:text-slate-500" />
<span className="text-sm font-medium">Inbox</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{items.length}
</span>
</div>
<button
type="button"
onClick={reset}
disabled={!canReset}
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:pointer-events-none disabled:opacity-40 dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
>
<RefreshIcon className="h-3.5 w-3.5" />
Reset{archivedCount > 0 ? ` (${archivedCount})` : ""}
</button>
</div>
<ul className="divide-y divide-slate-100 dark:divide-slate-800/70">
<AnimatePresence initial={false} mode="popLayout">
{items.map((item) => (
<motion.li
key={item.id}
layout={!reduce}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, x: -32 }}
transition={itemSpring}
className="group flex items-center gap-3 px-4 py-3"
>
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 rounded-full ${
item.unread ? "bg-indigo-500" : "bg-transparent ring-1 ring-inset ring-slate-300 dark:ring-slate-600"
}`}
/>
<div className="min-w-0 flex-1">
<p
className={`truncate text-sm ${
item.unread
? "font-semibold text-slate-900 dark:text-slate-50"
: "font-medium text-slate-700 dark:text-slate-200"
}`}
>
{item.title}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">{item.meta}</p>
</div>
<button
type="button"
onClick={() => archive(item.id)}
aria-label={`Archive: ${item.title}`}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 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-800 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-50 dark:focus-visible:ring-offset-slate-900"
>
<ArchiveIcon className="h-4 w-4" />
<span className="hidden sm:inline">Archive</span>
</button>
</motion.li>
))}
</AnimatePresence>
{items.length === 0 && (
<li className="flex flex-col items-center gap-3 px-6 py-14 text-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300">
<InboxIcon className="h-6 w-6" />
</span>
<div>
<p className="text-sm font-semibold">Inbox zero</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Everything is archived. Undo any toast that is still on screen, or reset the demo.
</p>
</div>
<button
type="button"
onClick={reset}
className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-3.5 py-2 text-xs font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
>
<RefreshIcon className="h-3.5 w-3.5" />
Restore inbox
</button>
</li>
)}
</ul>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
Tip: keyboard-focus a toast to hold the countdown while you decide.
</p>
</div>
{/* Toast viewport */}
<ol
aria-live="polite"
aria-label="Recent actions"
className="pointer-events-none fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full max-w-sm flex-col items-stretch gap-2 px-4 pb-4 sm:inset-x-auto sm:right-6 sm:bottom-6 sm:mx-0 sm:px-0"
>
<AnimatePresence initial mode="popLayout">
{toasts.map((t) => {
const pct = Math.max(0, Math.min(100, (t.remaining / t.duration) * 100));
return (
<motion.li
key={t.id}
role="status"
layout={!reduce}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 20, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, x: 48, scale: 0.94 }}
transition={toastSpring}
onPointerEnter={() => setPause(t.id, "hover", true)}
onPointerLeave={() => setPause(t.id, "hover", false)}
onFocus={() => setPause(t.id, "focus", true)}
onBlur={(e) => handleBlur(t.id, e)}
className="pointer-events-auto relative overflow-hidden rounded-xl border border-slate-200 bg-white/95 shadow-lg shadow-slate-900/10 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/40"
>
<div className="flex items-start gap-3 p-3 pr-2">
<span className="relative mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
<span
aria-hidden="true"
className="ta-pulse-ring absolute inset-0 rounded-lg bg-indigo-400/40 dark:bg-indigo-400/25"
/>
<ArchiveIcon className="relative h-4 w-4" />
</span>
<div className="min-w-0 flex-1 pt-0.5">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-50">
Conversation archived
</p>
<p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
{t.item.title}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5 pl-1">
<button
type="button"
onClick={() => undo(t.id)}
aria-label={`Undo archiving: ${t.item.title}`}
className="group relative inline-flex items-center gap-1.5 overflow-hidden rounded-lg px-2.5 py-1.5 text-xs font-semibold text-indigo-600 transition-colors hover:bg-indigo-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:text-indigo-300 dark:hover:bg-indigo-500/15 dark:focus-visible:ring-offset-slate-900"
>
<UndoIcon className="h-3.5 w-3.5" />
Undo
</button>
<button
type="button"
onClick={() => dismiss(t.id)}
aria-label="Dismiss and keep archived"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg 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 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
</div>
{/* Countdown track */}
<div className="h-1 w-full bg-slate-100 dark:bg-slate-800">
<div
className="relative h-full overflow-hidden rounded-r-full"
style={{ width: `${pct}%` }}
>
<span className="absolute inset-0 bg-indigo-500 dark:bg-indigo-400" />
<span className="ta-sheen absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/50 to-transparent" />
</div>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ol>
<style>{`
@keyframes ta-pulse-ring {
0% { transform: scale(1); opacity: 0.55; }
70% { transform: scale(1.55); opacity: 0; }
100% { transform: scale(1.55); opacity: 0; }
}
@keyframes ta-sheen {
0% { transform: translateX(-140%); }
100% { transform: translateX(520%); }
}
.ta-pulse-ring { animation: ta-pulse-ring 2.4s ease-out infinite; }
.ta-sheen { animation: ta-sheen 1.8s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.ta-pulse-ring, .ta-sheen { 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

Stacked Toast
Originalstacked toast queue

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.

