Basic Toast
Original · freebasic toast notifications
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-basic.json"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { FocusEvent, KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type ToastVariant = "success" | "error" | "info" | "warning";
type Position = "top-left" | "top-right" | "bottom-left" | "bottom-right";
interface ToastItem {
id: number;
variant: ToastVariant;
title: string;
description: string;
}
const DURATION = 5000;
const MAX_TOASTS = 4;
const MESSAGES: Record<ToastVariant, { title: string; description: string }[]> = {
success: [
{ title: "Payment received", description: "Invoice #INV-2043 has been settled in full." },
{ title: "Deploy finished", description: "Build v2.8.0 is live on production with zero errors." },
{ title: "Saved to workspace", description: "Your changes synced across 3 devices a moment ago." },
],
error: [
{ title: "Upload failed", description: "report-q3.pdf exceeded the 25 MB attachment limit." },
{ title: "Connection lost", description: "We couldn't reach the server. Retrying in 8 seconds." },
{ title: "Card declined", description: "The card ending 4291 was rejected by the issuer." },
],
info: [
{ title: "Sync complete", description: "42 records were pulled from the CRM connector." },
{ title: "New mention", description: "Priya tagged you in the “Q3 roadmap” thread." },
{ title: "Update available", description: "Version 2.9.0 is ready — restart to install." },
],
warning: [
{ title: "Storage almost full", description: "You've used 94% of your 10 GB workspace plan." },
{ title: "Session expiring", description: "You'll be signed out automatically in 5 minutes." },
{ title: "Unsaved changes", description: "Leaving this page now will discard 3 pending edits." },
],
};
const VARIANT_STYLES: Record<
ToastVariant,
{ iconWrap: string; bar: string; trigger: string; ring: string }
> = {
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:
"bg-emerald-50 text-emerald-700 ring-emerald-200 hover:bg-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/25 dark:hover:bg-emerald-500/20",
ring: "focus-visible:ring-emerald-500",
},
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:
"bg-rose-50 text-rose-700 ring-rose-200 hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/25 dark:hover:bg-rose-500/20",
ring: "focus-visible:ring-rose-500",
},
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:
"bg-sky-50 text-sky-700 ring-sky-200 hover:bg-sky-100 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/25 dark:hover:bg-sky-500/20",
ring: "focus-visible:ring-sky-500",
},
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:
"bg-amber-50 text-amber-700 ring-amber-200 hover:bg-amber-100 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/25 dark:hover:bg-amber-500/20",
ring: "focus-visible:ring-amber-500",
},
};
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" },
];
const POSITION_CLASSES: Record<Position, string> = {
"top-left": "top-4 left-4 items-start flex-col-reverse",
"top-right": "top-4 right-4 items-end flex-col-reverse",
"bottom-left": "bottom-4 left-4 items-start flex-col",
"bottom-right": "bottom-4 right-4 items-end flex-col",
};
function VariantIcon({ variant }: { variant: ToastVariant }): ReactNode {
const common = { className: "h-5 w-5", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
if (variant === "success") {
return (
<svg {...common} aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
if (variant === "error") {
return (
<svg {...common} aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="m15 9-6 6M9 9l6 6" />
</svg>
);
}
if (variant === "warning") {
return (
<svg {...common} aria-hidden="true">
<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 9v4M12 17h.01" />
</svg>
);
}
return (
<svg {...common} aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8h.01" />
</svg>
);
}
function Toast({
toast,
onDismiss,
reduced,
}: {
toast: ToastItem;
onDismiss: (id: number) => void;
reduced: boolean;
}) {
const [paused, setPaused] = useState(false);
const remainingRef = useRef(DURATION);
const startRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clear = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const start = useCallback(() => {
startRef.current = Date.now();
timerRef.current = setTimeout(() => onDismiss(toast.id), remainingRef.current);
}, [onDismiss, toast.id]);
useEffect(() => {
start();
return clear;
}, [start, clear]);
const pause = useCallback(() => {
setPaused((prev) => {
if (prev) return prev;
clear();
remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startRef.current));
return true;
});
}, [clear]);
const resume = useCallback(() => {
setPaused((prev) => {
if (!prev) return prev;
start();
return false;
});
}, [start]);
const onBlur = (event: FocusEvent<HTMLLIElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) resume();
};
const onKeyDown = (event: KeyboardEvent<HTMLLIElement>) => {
if (event.key === "Escape") {
event.stopPropagation();
onDismiss(toast.id);
}
};
const styles = VARIANT_STYLES[toast.variant];
const assertive = toast.variant === "error" || toast.variant === "warning";
return (
<motion.li
layout={!reduced}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.96 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, transition: { duration: 0.18 } }}
transition={{ duration: reduced ? 0.15 : 0.32, ease: [0.22, 1, 0.36, 1] }}
role={assertive ? "alert" : "status"}
aria-live={assertive ? "assertive" : "polite"}
aria-atomic="true"
onMouseEnter={pause}
onMouseLeave={resume}
onFocus={pause}
onBlur={onBlur}
onKeyDown={onKeyDown}
className="pointer-events-auto relative w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30"
>
<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 ${styles.iconWrap}`}
>
<VariantIcon variant={toast.variant} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{toast.title}</p>
<p className="mt-0.5 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{toast.description}
</p>
</div>
<button
type="button"
onClick={() => onDismiss(toast.id)}
aria-label={`Dismiss notification: ${toast.title}`}
className="-mr-1 -mt-1 shrink-0 rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 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 className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
{!reduced && (
<div className="absolute inset-x-0 bottom-0 h-1 bg-slate-100 dark:bg-slate-800" aria-hidden="true">
<div
className={`tb-progress-bar h-full w-full ${styles.bar}`}
style={{ animationDuration: `${DURATION}ms`, animationPlayState: paused ? "paused" : "running" }}
/>
</div>
)}
</motion.li>
);
}
export default function ToastBasic() {
const reduced = useReducedMotion() ?? false;
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [position, setPosition] = useState<Position>("bottom-right");
const idRef = useRef(0);
const cycleRef = useRef<Record<ToastVariant, number>>({ success: 0, error: 0, info: 0, warning: 0 });
const push = useCallback((variant: ToastVariant) => {
const pool = MESSAGES[variant];
const index = cycleRef.current[variant] % pool.length;
cycleRef.current[variant] += 1;
const message = pool[index];
const id = (idRef.current += 1);
setToasts((prev) => {
const next = [...prev, { id, variant, ...message }];
return next.slice(Math.max(0, next.length - MAX_TOASTS));
});
}, []);
const remove = useCallback((id: number) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const clearAll = useCallback(() => setToasts([]), []);
const triggers: { variant: ToastVariant; label: string }[] = [
{ variant: "success", label: "Success" },
{ variant: "error", label: "Error" },
{ variant: "info", label: "Info" },
{ variant: "warning", label: "Warning" },
];
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 lg:py-24 dark:bg-slate-950">
<style>{`
@keyframes tb-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
.tb-progress-bar { transform-origin: left center; animation-name: tb-progress; animation-timing-function: linear; animation-fill-mode: forwards; }
@media (prefers-reduced-motion: reduce) {
.tb-progress-bar { animation: none !important; }
}
`}</style>
<div
className="pointer-events-none absolute z-10 h-40 w-40 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-500/15"
style={{ top: "-2rem", right: "-2rem" }}
aria-hidden="true"
/>
<div className="relative mx-auto max-w-2xl">
<div className="text-center">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Feedback & notifications
</p>
<h2 className="mt-3 text-balance text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Toast notifications
</h2>
<p className="mx-auto mt-4 max-w-lg text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
Fire a stackable, auto-dismissing toast. Hover or focus one to pause its
timer, press <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-xs font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">Esc</kbd> to
dismiss it, and pick where new toasts appear.
</p>
</div>
<div className="mt-10 rounded-2xl border border-slate-200 bg-white/70 p-5 shadow-sm backdrop-blur sm:p-6 dark:border-slate-800 dark:bg-slate-900/60">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{triggers.map(({ variant, label }) => {
const styles = VARIANT_STYLES[variant];
return (
<button
key={variant}
type="button"
onClick={() => push(variant)}
className={`inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold ring-1 transition 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 ${styles.trigger} ${styles.ring}`}
>
<VariantIcon variant={variant} />
{label}
</button>
);
})}
</div>
<div className="mt-5 flex flex-col gap-4 border-t border-slate-200 pt-5 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
<fieldset>
<legend className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
Position
</legend>
<div className="inline-flex flex-wrap gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800">
{POSITIONS.map((option) => (
<label key={option.value} className="cursor-pointer">
<input
type="radio"
name="tb-position"
value={option.value}
checked={position === option.value}
onChange={() => setPosition(option.value)}
className="peer sr-only"
/>
<span className="block rounded-md px-3 py-1.5 text-xs font-medium text-slate-600 ring-1 ring-transparent transition peer-checked:bg-white peer-checked:text-slate-900 peer-checked:shadow-sm peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 dark:text-slate-400 dark:peer-checked:bg-slate-700 dark:peer-checked:text-white">
{option.label}
</span>
</label>
))}
</div>
</fieldset>
<div className="flex items-center gap-3">
<span aria-live="polite" className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
{toasts.length} active
</span>
<button
type="button"
onClick={clearAll}
disabled={toasts.length === 0}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
Clear all
</button>
</div>
</div>
</div>
</div>
<ol
role="region"
aria-label="Notifications"
className={`pointer-events-none fixed z-30 flex w-[min(22rem,calc(100vw-2rem))] gap-3 ${POSITION_CLASSES[position]}`}
>
<AnimatePresence mode="popLayout">
{toasts.map((toast) => (
<Toast key={toast.id} toast={toast} onDismiss={remove} reduced={reduced} />
))}
</AnimatePresence>
</ol>
</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 →
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

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.

