Progress Toast
Original · freetoast with auto-dismiss progress
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-progress.json"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ToastType = "success" | "error" | "info" | "warning";
interface ToastData {
id: number;
type: ToastType;
title: string;
message: string;
duration: number;
}
interface ToastConfig {
label: string;
iconWrap: string;
bar: string;
trigger: string;
icon: ReactNode;
}
const TOAST_CONFIG: Record<ToastType, ToastConfig> = {
success: {
label: "Success",
iconWrap:
"bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400",
bar: "bg-emerald-500 dark:bg-emerald-400",
trigger:
"border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-300 dark:hover:bg-emerald-500/20 focus-visible:ring-emerald-500",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<path d="m22 4-10 10-3-3" />
</svg>
),
},
error: {
label: "Error",
iconWrap:
"bg-rose-100 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400",
bar: "bg-rose-500 dark:bg-rose-400",
trigger:
"border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/25 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20 focus-visible:ring-rose-500",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
),
},
info: {
label: "Info",
iconWrap:
"bg-sky-100 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400",
bar: "bg-sky-500 dark:bg-sky-400",
trigger:
"border-sky-200 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:hover:bg-sky-500/20 focus-visible:ring-sky-500",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
),
},
warning: {
label: "Warning",
iconWrap:
"bg-amber-100 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400",
bar: "bg-amber-500 dark:bg-amber-400",
trigger:
"border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-300 dark:hover:bg-amber-500/20 focus-visible:ring-amber-500",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
aria-hidden="true"
>
<path d="m10.29 3.86-8.18 14.14A2 2 0 0 0 3.83 21h16.34a2 2 0 0 0 1.72-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
),
},
};
const MESSAGES: Record<ToastType, ReadonlyArray<{ title: string; message: string }>> = {
success: [
{ title: "Deployment live", message: "webinnoventix.com shipped to production in 42 seconds." },
{ title: "Payment received", message: "Invoice #2041 for $1,800 was settled by Northwind Studio." },
{ title: "Export ready", message: "Your 8,400-row analytics report finished. Download it now." },
],
error: [
{ title: "Upload failed", message: "hero-banner.webp is 7.2 MB — over the 5 MB limit. Try a smaller file." },
{ title: "Connection lost", message: "We could not reach the API. Retrying in 5 seconds." },
{ title: "Payment declined", message: "Card ending 4242 was declined. Update your billing details." },
],
info: [
{ title: "Sync in progress", message: "Importing 1,248 contacts from contacts.csv in the background." },
{ title: "New version available", message: "v2.4 is ready. Reload to get the latest editor improvements." },
{ title: "Scheduled maintenance", message: "Brief downtime is planned for Sunday 02:00–02:30 UTC." },
],
warning: [
{ title: "Storage almost full", message: "You have used 92% of your 10 GB plan. Upgrade to avoid limits." },
{ title: "Session expiring", message: "You will be signed out in 3 minutes unless there is activity." },
{ title: "Unsaved changes", message: "Two edits are not saved yet. Leaving now will discard them." },
],
};
const TYPES: ReadonlyArray<ToastType> = ["success", "error", "info", "warning"];
const DURATIONS: ReadonlyArray<{ ms: number; label: string }> = [
{ ms: 3000, label: "3s" },
{ ms: 5000, label: "5s" },
{ ms: 8000, label: "8s" },
];
const MAX_TOASTS = 4;
interface ToastCardProps {
toast: ToastData;
reduced: boolean;
onDismiss: (id: number) => void;
}
function ToastCard({ toast, reduced, onDismiss }: ToastCardProps) {
const cfg = TOAST_CONFIG[toast.type];
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [ariaValue, setAriaValue] = useState(100);
const paused = hovered || focused;
const pausedRef = useRef(false);
const remainingRef = useRef(toast.duration);
const barRef = useRef<HTMLDivElement | null>(null);
const lastAriaRef = useRef(100);
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
useEffect(() => {
let raf = 0;
let last = performance.now();
const tick = (now: number) => {
const dt = now - last;
last = now;
if (!pausedRef.current) {
remainingRef.current = Math.max(0, remainingRef.current - dt);
}
const ratio = remainingRef.current / toast.duration;
if (barRef.current) {
barRef.current.style.transform = `scaleX(${ratio})`;
}
const pct = Math.round(ratio * 100);
if (Math.abs(pct - lastAriaRef.current) >= 4 || pct === 0 || pct === 100) {
lastAriaRef.current = pct;
setAriaValue(pct);
}
if (remainingRef.current <= 0) {
onDismiss(toast.id);
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [toast.id, toast.duration, onDismiss]);
const handleKeyDown = (event: KeyboardEvent<HTMLLIElement>) => {
if (event.key === "Escape") {
event.stopPropagation();
onDismiss(toast.id);
}
};
return (
<motion.li
layout
initial={reduced ? { opacity: 0 } : { opacity: 0, x: 48, scale: 0.96 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, x: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, x: 48, scale: 0.96 }}
transition={
reduced
? { duration: 0.15 }
: { type: "spring", stiffness: 420, damping: 34, mass: 0.8 }
}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setFocused(false);
}
}}
onKeyDown={handleKeyDown}
role="group"
aria-label={`${cfg.label} notification`}
className="pointer-events-auto overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg shadow-zinc-900/10 ring-1 ring-black/[0.02] dark:border-zinc-700/60 dark:bg-zinc-900 dark:shadow-black/40"
>
<div className="flex items-start gap-3 p-4">
<span
className={`mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-lg ${cfg.iconWrap} ${reduced ? "" : "tprog-pop"}`}
>
{cfg.icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{toast.title}
</p>
{paused && (
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
<svg viewBox="0 0 24 24" fill="currentColor" className="h-2.5 w-2.5" aria-hidden="true">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
Paused
</span>
)}
</div>
<p className="mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{toast.message}
</p>
</div>
<button
type="button"
onClick={() => onDismiss(toast.id)}
aria-label={`Dismiss ${cfg.label.toLowerCase()} notification`}
className="-mr-1 -mt-1 grid h-7 w-7 shrink-0 place-items-center rounded-md text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-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:hover:bg-zinc-800 dark:hover:text-zinc-200 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
<div
role="progressbar"
aria-label="Time remaining before dismiss"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={ariaValue}
className="relative h-1.5 w-full overflow-hidden bg-zinc-100 dark:bg-zinc-800"
>
<div
ref={barRef}
className={`h-full origin-left rounded-r-full ${cfg.bar}`}
style={{ transform: "scaleX(1)" }}
/>
{!reduced && (
<span
aria-hidden="true"
className="tprog-shimmer pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/50 to-transparent dark:via-white/20"
style={{ animationPlayState: paused ? "paused" : "running" }}
/>
)}
</div>
</motion.li>
);
}
export default function ToastProgress() {
const prefersReduced = useReducedMotion();
const reduced = prefersReduced ?? false;
const [toasts, setToasts] = useState<ToastData[]>([]);
const [duration, setDuration] = useState<number>(5000);
const [announce, setAnnounce] = useState("");
const idRef = useRef(0);
const cursorRef = useRef<Record<ToastType, number>>({
success: 0,
error: 0,
info: 0,
warning: 0,
});
const durationRef = useRef(duration);
const durationBtnRefs = useRef<Array<HTMLButtonElement | null>>([]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
const addToast = useCallback((type: ToastType) => {
const pool = MESSAGES[type];
const index = cursorRef.current[type] % pool.length;
cursorRef.current[type] += 1;
const { title, message } = pool[index];
idRef.current += 1;
const id = idRef.current;
setToasts((prev) =>
[...prev, { id, type, title, message, duration: durationRef.current }].slice(-MAX_TOASTS),
);
setAnnounce(`${TOAST_CONFIG[type].label}: ${title}. ${message}`);
}, []);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const clearAll = useCallback(() => {
setToasts([]);
setAnnounce("All notifications dismissed.");
}, []);
const handleDurationKey = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next = index;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
next = (index + 1) % DURATIONS.length;
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
next = (index - 1 + DURATIONS.length) % DURATIONS.length;
} else {
return;
}
event.preventDefault();
setDuration(DURATIONS[next].ms);
durationBtnRefs.current[next]?.focus();
};
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-zinc-50 to-white px-4 py-16 text-zinc-900 dark:from-zinc-950 dark:to-zinc-900 dark:text-zinc-50 sm:px-6 sm:py-20">
<style>{`
@keyframes tprog-shimmer {
0% { transform: translateX(-120%); }
100% { transform: translateX(420%); }
}
@keyframes tprog-pop {
0% { transform: scale(0.5); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.tprog-shimmer { animation: tprog-shimmer 1.8s linear infinite; }
.tprog-pop { animation: tprog-pop 0.42s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
@media (prefers-reduced-motion: reduce) {
.tprog-shimmer, .tprog-pop { animation: none !important; }
}
`}</style>
<div aria-live="polite" role="status" className="sr-only">
{announce}
</div>
<div className="mx-auto max-w-5xl">
<div className="max-w-2xl">
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Notifications
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">
Toasts that show their own clock
</h2>
<p className="mt-3 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
Every toast auto-dismisses on a countdown you can watch. Hover, focus, or tab into one
and the timer pauses, so nothing vanishes while you are still reading it.
</p>
</div>
<div className="relative mt-10 min-h-[460px] overflow-hidden rounded-2xl border border-zinc-200 bg-white/60 p-6 backdrop-blur-sm dark:border-zinc-800 dark:bg-zinc-900/40 sm:p-10">
<div
aria-hidden="true"
className="pointer-events-none absolute -right-16 -top-16 h-64 w-64 rounded-full bg-indigo-400/10 blur-3xl dark:bg-indigo-500/10"
/>
<div className="relative mx-auto max-w-md text-center">
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
Trigger a notification
</p>
<div className="mt-4 flex flex-wrap justify-center gap-2.5">
{TYPES.map((type) => (
<button
key={type}
type="button"
onClick={() => addToast(type)}
className={`inline-flex items-center gap-2 rounded-lg border px-3.5 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${TOAST_CONFIG[type].trigger}`}
>
<span className="h-4 w-4">{TOAST_CONFIG[type].icon}</span>
{TOAST_CONFIG[type].label}
</button>
))}
</div>
<div className="mt-8 flex flex-col items-center gap-3">
<div className="flex items-center gap-3">
<span
id="tprog-duration-label"
className="text-sm font-medium text-zinc-500 dark:text-zinc-400"
>
Auto-dismiss after
</span>
<div
role="radiogroup"
aria-labelledby="tprog-duration-label"
className="inline-flex rounded-lg border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-700/60 dark:bg-zinc-800/60"
>
{DURATIONS.map((option, index) => {
const selected = option.ms === duration;
return (
<button
key={option.ms}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
ref={(el) => {
durationBtnRefs.current[index] = el;
}}
onClick={() => setDuration(option.ms)}
onKeyDown={(event) => handleDurationKey(event, index)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-800 ${
selected
? "bg-zinc-900 text-white shadow-sm dark:bg-white dark:text-zinc-900"
: "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
}`}
>
{option.label}
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="text-zinc-500 dark:text-zinc-400" aria-live="off">
{toasts.length} active
</span>
<span aria-hidden="true" className="text-zinc-300 dark:text-zinc-700">
•
</span>
<button
type="button"
onClick={clearAll}
disabled={toasts.length === 0}
className="font-medium text-zinc-600 underline-offset-4 transition hover:underline disabled:cursor-not-allowed disabled:text-zinc-300 disabled:no-underline 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-zinc-300 dark:disabled:text-zinc-700 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
Clear all
</button>
</div>
<p className="mt-1 text-xs text-zinc-400 dark:text-zinc-500">
Tip: press Escape while a toast is focused to close it.
</p>
</div>
</div>
<ul
aria-label="Notifications"
className="pointer-events-none absolute bottom-5 right-5 z-10 flex w-[336px] max-w-[calc(100%-2.5rem)] flex-col gap-3"
>
<AnimatePresence initial={false}>
{toasts.map((toast) => (
<ToastCard
key={toast.id}
toast={toast}
reduced={reduced}
onDismiss={removeToast}
/>
))}
</AnimatePresence>
</ul>
</div>
</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 →
Basic Toast
Originalbasic toast notifications

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

Action Toast
Originaltoast with an undo action

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.

