Toast Notifications
Stacking toasts with layout animation and auto-dismiss.
components/ui/toast-stack.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
import { useCallback, useState } from "react";
type ToastKind = "success" | "info" | "warning";
type Toast = { id: number; kind: ToastKind; title: string; text: string };
const icons = {
success: <CheckCircle2 className="size-5 text-emerald-600" />,
info: <Info className="size-5 text-blue-600" />,
warning: <AlertTriangle className="size-5 text-amber-500" />,
};
export function useToasts(duration = 4000) {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = useCallback((id: number) => setToasts((t) => t.filter((x) => x.id !== id)), []);
const push = useCallback(
(toast: Omit<Toast, "id">) => {
const id = Date.now() + Math.random();
setToasts((t) => [...t.slice(-3), { ...toast, id }]);
setTimeout(() => dismiss(id), duration);
},
[dismiss, duration],
);
return { toasts, push, dismiss };
}
export function ToastStack({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
return (
<div aria-live="polite" className="pointer-events-none flex w-80 flex-col gap-2">
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 60, transition: { duration: 0.2 } }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
className="pointer-events-auto flex items-start gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-lg shadow-zinc-900/5"
>
{icons[t.kind]}
<div className="flex-1">
<p className="text-sm font-medium text-zinc-900">{t.title}</p>
<p className="text-sm text-zinc-500">{t.text}</p>
</div>
<button aria-label="Dismiss" onClick={() => onDismiss(t.id)} className="text-zinc-400 hover:text-zinc-700">
<X className="size-4" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
export default function Demo() {
const { toasts, push, dismiss } = useToasts();
return (
<div className="flex w-full max-w-2xl flex-col items-center gap-6 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-wrap gap-2">
<button onClick={() => push({ kind: "success", title: "Changes saved", text: "Your profile has been updated." })} className="rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50">
Success
</button>
<button onClick={() => push({ kind: "info", title: "New version", text: "v2.4 is ready to install." })} className="rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50">
Info
</button>
<button onClick={() => push({ kind: "warning", title: "Storage almost full", text: "You've used 92% of your plan." })} className="rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50">
Warning
</button>
</div>
<div className="flex min-h-56 items-end">
<ToastStack toasts={toasts} onDismiss={dismiss} />
</div>
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/toast-stack.tsxcomponents/ui/toast-stack.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react"; import { useCallback, useState } from "react"; type ToastKind = "success" | "info" | "warning"; type Toast = { id: number; kind: ToastKind; title: string; text: string }; const icons = { success: <CheckCircle2 className="size-5 text-emerald-600" />, info: <Info className="size-5 text-blue-600" />, warning: <AlertTriangle className="size-5 text-amber-500" />, }; export function useToasts(duration = 4000) { const [toasts, setToasts] = useState<Toast[]>([]); const dismiss = useCallback((id: number) => setToasts((t) => t.filter((x) => x.id !== id)), []); const push = useCallback( (toast: Omit<Toast, "id">) => { const id = Date.now() + Math.random(); setToasts((t) => [...t.slice(-3), { ...toast, id }]); setTimeout(() => dismiss(id), duration); }, [dismiss, duration], ); return { toasts, push, dismiss }; } export function ToastStack({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) { return ( <div aria-live="polite" className="pointer-events-none flex w-80 flex-col gap-2"> <AnimatePresence initial={false}> {toasts.map((t) => ( <motion.div key={t.id} layout initial={{ opacity: 0, y: 20, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, x: 60, transition: { duration: 0.2 } }} transition={{ type: "spring", stiffness: 380, damping: 30 }} className="pointer-events-auto flex items-start gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-lg shadow-zinc-900/5" > {icons[t.kind]} <div className="flex-1"> <p className="text-sm font-medium text-zinc-900">{t.title}</p> <p className="text-sm text-zinc-500">{t.text}</p> </div> <button aria-label="Dismiss" onClick={() => onDismiss(t.id)} className="text-zinc-400 hover:text-zinc-700"> <X className="size-4" /> </button> </motion.div> ))} </AnimatePresence> </div> ); } export default function Demo() { const { toasts, push, dismiss } = useToasts(); return ( <div className="flex w-full max-w-2xl flex-col items-center gap-6 sm:flex-row sm:items-end sm:justify-between"> <div className="flex flex-wrap gap-2"> <button onClick={() => push({ kind: "success", title: "Changes saved", text: "Your profile has been updated." })} className="rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50"> Success </button> <button onClick={() => push({ kind: "info", title: "New version", text: "v2.4 is ready to install." })} className="rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50"> Info </button> <button onClick={() => push({ kind: "warning", title: "Storage almost full", text: "You've used 92% of your plan." })} className="rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50"> Warning </button> </div> <div className="flex min-h-56 items-end"> <ToastStack toasts={toasts} onDismiss={dismiss} /> </div> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.