Promise Toast
Original · freeloading-to-result promise toast
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-promise.json"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "loading" | "success" | "error";
type Accent = "indigo" | "violet" | "sky";
type Copy = { title: string; description: string };
type TaskDef = {
key: string;
label: string;
accent: Accent;
loading: Copy;
success: Copy;
error: Copy;
};
type Toast = {
id: number;
status: Status;
title: string;
description: string;
task: TaskDef;
};
type TimerState = {
remaining: number;
deadline: number;
timeout: ReturnType<typeof setTimeout> | null;
};
const AUTO_DISMISS = 5000;
const TASKS: TaskDef[] = [
{
key: "save",
label: "Save draft",
accent: "indigo",
loading: { title: "Saving your draft…", description: "Syncing the latest edits." },
success: { title: "Draft saved", description: "Every change is backed up to the cloud." },
error: { title: "Couldn't save draft", description: "The workspace didn't respond. Give it another go." },
},
{
key: "deploy",
label: "Deploy build",
accent: "violet",
loading: { title: "Deploying build…", description: "Rolling v2.4.1 out to production." },
success: { title: "Deploy complete", description: "v2.4.1 is live for every region." },
error: { title: "Deploy failed", description: "Build step 4 exited with code 1." },
},
{
key: "invite",
label: "Send invite",
accent: "sky",
loading: { title: "Sending invite…", description: "Reaching jordan@northwind.io." },
success: { title: "Invite sent", description: "Jordan will get an email in a moment." },
error: { title: "Invite not sent", description: "That address bounced back to us." },
},
];
const ACCENTS: Record<Accent, string> = {
indigo: "bg-indigo-600 hover:bg-indigo-500 focus-visible:ring-indigo-500",
violet: "bg-violet-600 hover:bg-violet-500 focus-visible:ring-violet-500",
sky: "bg-sky-600 hover:bg-sky-500 focus-visible:ring-sky-500",
};
const RING: Record<Status, string> = {
loading: "bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300",
success: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300",
error: "bg-rose-50 text-rose-600 dark:bg-rose-500/15 dark:text-rose-300",
};
function TaskIcon({ k }: { k: string }) {
if (k === "save") {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
<path d="M6 4h9l3 3v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z" strokeLinejoin="round" />
<path d="M8 4v5h6V4M8 14h8v5H8z" strokeLinejoin="round" />
</svg>
);
}
if (k === "deploy") {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
<path d="M12 3c3 1.6 5 4.7 5 8.2L14 14H10L7 11.2C7 7.7 9 4.6 12 3Z" strokeLinejoin="round" />
<circle cx="12" cy="9.2" r="1.5" />
<path d="M9 15l-2 4 3.2-1.5M15 15l2 4-3.2-1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
<path d="M21 3 3 11l7 3 3 7 8-18Z" strokeLinejoin="round" />
<path d="M21 3 10 14" strokeLinecap="round" />
</svg>
);
}
function StatusIcon({ status }: { status: Status }) {
if (status === "loading") {
return (
<svg viewBox="0 0 24 24" className="tp-spin h-5 w-5" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" className="stroke-current opacity-20" strokeWidth="3" />
<path d="M21 12a9 9 0 0 0-9-9" className="stroke-current" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
if (status === "success") {
return (
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
<path d="M20 6 9 17l-5-5" className="stroke-current" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" className="stroke-current" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}
export default function ToastPromise() {
const reduce = useReducedMotion() ?? false;
const [toasts, setToasts] = useState<Toast[]>([]);
const [forceFail, setForceFail] = useState(false);
const [pausedIds, setPausedIds] = useState<Set<number>>(() => new Set());
const idRef = useRef(1);
const forceFailRef = useRef(forceFail);
const pausedIdsRef = useRef(pausedIds);
const timers = useRef<Map<number, TimerState>>(new Map());
useEffect(() => {
forceFailRef.current = forceFail;
}, [forceFail]);
useEffect(() => {
pausedIdsRef.current = pausedIds;
}, [pausedIds]);
const removeToast = useCallback((id: number) => {
const t = timers.current.get(id);
if (t?.timeout) clearTimeout(t.timeout);
timers.current.delete(id);
setToasts((prev) => prev.filter((x) => x.id !== id));
setPausedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
}, []);
const startTimer = useCallback(
(id: number, duration: number) => {
const existing = timers.current.get(id);
if (existing?.timeout) clearTimeout(existing.timeout);
const timeout = setTimeout(() => removeToast(id), duration);
timers.current.set(id, { remaining: duration, deadline: Date.now() + duration, timeout });
},
[removeToast],
);
const handlePause = useCallback((id: number) => {
const t = timers.current.get(id);
if (t?.timeout) {
clearTimeout(t.timeout);
t.remaining = Math.max(0, t.deadline - Date.now());
t.timeout = null;
}
setPausedIds((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
next.add(id);
return next;
});
}, []);
const handleResume = useCallback(
(id: number) => {
const t = timers.current.get(id);
if (t && !t.timeout) {
t.deadline = Date.now() + t.remaining;
t.timeout = setTimeout(() => removeToast(id), t.remaining);
}
setPausedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
},
[removeToast],
);
const runTask = useCallback(
(task: TaskDef, existingId?: number) => {
const id = existingId ?? idRef.current++;
const existing = timers.current.get(id);
if (existing?.timeout) clearTimeout(existing.timeout);
timers.current.delete(id);
setPausedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
const loading: Toast = {
id,
status: "loading",
title: task.loading.title,
description: task.loading.description,
task,
};
setToasts((prev) => {
if (existingId !== undefined && prev.some((t) => t.id === id)) {
return prev.map((t) => (t.id === id ? loading : t));
}
return [...prev, loading];
});
const willFail = forceFailRef.current;
const delay = 1500 + Math.round(Math.random() * 900);
new Promise<void>((resolve, reject) => {
setTimeout(() => (willFail ? reject(new Error("failed")) : resolve()), delay);
})
.then(() => {
setToasts((prev) =>
prev.map((t) =>
t.id === id
? { ...t, status: "success", title: task.success.title, description: task.success.description }
: t,
),
);
if (pausedIdsRef.current.has(id)) {
timers.current.set(id, {
remaining: AUTO_DISMISS,
deadline: Date.now() + AUTO_DISMISS,
timeout: null,
});
} else {
startTimer(id, AUTO_DISMISS);
}
})
.catch(() => {
setToasts((prev) =>
prev.map((t) =>
t.id === id
? { ...t, status: "error", title: task.error.title, description: task.error.description }
: t,
),
);
});
},
[startTimer],
);
const dismissAll = useCallback(() => {
timers.current.forEach((t) => {
if (t.timeout) clearTimeout(t.timeout);
});
timers.current.clear();
setToasts([]);
setPausedIds(new Set());
}, []);
useEffect(() => {
const map = timers.current;
return () => {
map.forEach((t) => {
if (t.timeout) clearTimeout(t.timeout);
});
map.clear();
};
}, []);
const enterProps = reduce
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.14 },
}
: {
initial: { opacity: 0, y: 26, scale: 0.95 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, scale: 0.9, transition: { duration: 0.18 } },
transition: { type: "spring" as const, stiffness: 420, damping: 34, mass: 0.8 },
};
const knobTransition = reduce
? { duration: 0 }
: { type: "spring" as const, stiffness: 500, damping: 34 };
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-20 dark:from-slate-950 dark:to-slate-900 sm:py-28">
<style>{`
@keyframes tp-spin { to { transform: rotate(360deg); } }
@keyframes tp-bar { from { transform: scaleX(1); } to { transform: scaleX(0); } }
.tp-spin { animation: tp-spin 0.7s linear infinite; }
.tp-bar { transform-origin: left center; animation-name: tp-bar; animation-timing-function: linear; animation-fill-mode: forwards; }
@media (prefers-reduced-motion: reduce) {
.tp-spin { animation: none; }
.tp-bar { animation: none; transform: scaleX(1); }
}
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -left-24 top-1/4 h-72 w-72 rounded-full bg-indigo-300/20 blur-3xl dark:bg-indigo-600/10" />
<div className="absolute -right-16 bottom-1/4 h-72 w-72 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-600/10" />
</div>
<div className="relative z-10 mx-auto max-w-5xl">
<div className="mx-auto max-w-2xl text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Async feedback
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
Promise toasts
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Fire an action and watch a single toast travel from loading to its result. Hover or focus a
toast to pause its auto-dismiss, or force a failure and retry it in place.
</p>
</div>
<div className="mx-auto mt-10 max-w-2xl rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 sm:p-8">
<div className="flex flex-col gap-3 sm:flex-row">
{TASKS.map((task) => (
<button
key={task.key}
type="button"
onClick={() => runTask(task)}
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold text-white shadow-sm transition active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${ACCENTS[task.accent]}`}
>
<TaskIcon k={task.key} />
{task.label}
</button>
))}
</div>
<div className="mt-6 flex flex-col gap-4 border-t border-slate-200 pt-5 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
aria-checked={forceFail}
aria-labelledby="tp-forcefail-label"
onClick={() => setForceFail((v) => !v)}
className={`inline-flex h-6 w-11 shrink-0 items-center rounded-full px-0.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
forceFail ? "bg-rose-500" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<motion.span
className="inline-block h-5 w-5 rounded-full bg-white shadow"
animate={{ x: forceFail ? 20 : 0 }}
transition={knobTransition}
/>
</button>
<span id="tp-forcefail-label" className="text-sm font-medium text-slate-700 dark:text-slate-300">
Force the next action to fail
</span>
</div>
<button
type="button"
onClick={dismissAll}
disabled={toasts.length === 0}
className="inline-flex items-center gap-2 self-start rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition disabled:cursor-not-allowed disabled:opacity-50 enabled:hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:enabled:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900 sm:self-auto"
>
Clear all
{toasts.length > 0 && (
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 text-xs font-semibold text-white dark:bg-white dark:text-slate-900">
{toasts.length}
</span>
)}
</button>
</div>
</div>
</div>
<ul
aria-label="Notifications"
className="pointer-events-none absolute inset-x-4 bottom-4 z-30 flex flex-col gap-3 sm:inset-x-auto sm:bottom-6 sm:right-6 sm:w-[22rem]"
>
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.li
key={t.id}
layout
{...enterProps}
role={t.status === "error" ? "alert" : "status"}
aria-atomic="true"
onMouseEnter={() => handlePause(t.id)}
onMouseLeave={() => handleResume(t.id)}
onFocus={() => handlePause(t.id)}
onBlur={() => handleResume(t.id)}
className="pointer-events-auto relative overflow-hidden rounded-2xl border border-slate-200 bg-white/95 shadow-lg shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/95 dark:shadow-black/40"
>
<div className="flex items-start gap-3 p-4">
<span
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${RING[t.status]}`}
>
<StatusIcon status={t.status} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{t.title}</p>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">{t.description}</p>
{t.status === "error" && (
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => runTask(t.task, t.id)}
className="inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-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"
>
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" aria-hidden="true">
<path
d="M3 12a9 9 0 1 0 3-6.7"
className="stroke-current"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M3 4v5h5"
className="stroke-current"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Retry
</button>
<button
type="button"
onClick={() => removeToast(t.id)}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-slate-500 transition hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
>
Dismiss
</button>
</div>
)}
</div>
<button
type="button"
onClick={() => removeToast(t.id)}
aria-label={`Dismiss ${t.title}`}
className="-mr-1 -mt-1 shrink-0 rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
<path
d="M18 6 6 18M6 6l12 12"
className="stroke-current"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{t.status === "success" && (
<div className="absolute inset-x-0 bottom-0 h-1 bg-slate-100 dark:bg-slate-800">
<div
className="tp-bar h-full bg-emerald-500 dark:bg-emerald-400"
style={{
animationDuration: `${AUTO_DISMISS}ms`,
animationPlayState: pausedIds.has(t.id) ? "paused" : "running",
}}
/>
</div>
)}
</motion.li>
))}
</AnimatePresence>
</ul>
</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

Stacked Toast
Originalstacked toast queue

Slide In Toast
Originalslide-in corner toasts

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.

