Toast
A layered toast that stacks into a pile, expands on hover, and counts down through a draining bed of colored pixels instead of a plain bar.
Toasts land from below as a bezel-and-panel card, pile up newest in front, and
fan open when you hover the stack. Each type carries a lucide status mark in its
own accent, the auto-dismiss timer drains as a two-row bed of colored squares,
and toast.promise resolves a spinning pending chip in place into a receipt.
Built with Motion.
Installation
Complete the shared Setup first, then copy the component into
components/ui/toast.tsx.
bun add motion lucide-react'use client';import * as React from 'react';import { motion, AnimatePresence, useReducedMotion } from 'motion/react';import { Check, TriangleAlert, Diamond, Loader, type LucideIcon,} from 'lucide-react';import { cn } from '@/lib/cn';const EASE = [0.23, 1, 0.32, 1] as const;export type ToastType = 'success' | 'error' | 'info' | 'pending';const LOOK: Record< ToastType, { tint: string; deep: string; accent: string }> = { success: { tint: '#5BD79C', deep: '#22A56E', accent: '#48CE8D' }, error: { tint: '#F5897F', deep: '#DA463A', accent: '#F0736A' }, info: { tint: '#93B9FF', deep: '#5B84E6', accent: '#93B9FF' }, pending: { tint: '#8C8C84', deep: '#5C5C55', accent: '#A2A29A' },};const ICON: Record<ToastType, LucideIcon> = { success: Check, error: TriangleAlert, info: Diamond, pending: Loader,};const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));function ramp(a: string, b: string, t: number, k: number) { const A = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16)); const B = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16)); const c = A.map((v, i) => clamp((v + (B[i] - v) * t) * k, 0, 255) | 0); return `rgb(${c[0]},${c[1]},${c[2]})`;}function jitter(r: number, c: number) { const h = ((r * 73856093) ^ (c * 19349663)) >>> 0; return (h % 1000) / 1000;}const PX_COLS = 36;const PX_ROWS = 2;function PixelTimer({ type, duration, paused, reduce, onDone,}: { type: ToastType; duration: number; paused: boolean; reduce: boolean | null; onDone: () => void;}) { const l = LOOK[type]; const [spent, setSpent] = React.useState(0); const pausedRef = React.useRef(paused); pausedRef.current = paused; const doneRef = React.useRef(onDone); doneRef.current = onDone; React.useEffect(() => { if (reduce) return; let raf = 0; let prev = performance.now(); let elapsed = 0; let stopped = false; const loop = (now: number) => { if (stopped) return; const dt = now - prev; prev = now; if (!pausedRef.current) elapsed += dt; const p = Math.min(1, elapsed / duration); const s = Math.floor(p * PX_COLS); setSpent((prevS) => (prevS === s ? prevS : s)); if (p >= 1) { doneRef.current(); return; } raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => { stopped = true; cancelAnimationFrame(raf); }; }, [duration, reduce]); const cells: React.ReactNode[] = []; for (let r = 0; r < PX_ROWS; r++) { for (let c = 0; c < PX_COLS; c++) { const on = c >= spent; const t = c / (PX_COLS - 1); const j = jitter(r, c); cells.push( <span key={`${r}-${c}`} className="h-[3.5px] rounded-[1px]" style={ on ? { background: ramp(l.deep, l.tint, t, 0.68 + 0.52 * j), opacity: 0.6 + 0.4 * j, } : { background: 'var(--tst-px-off)', opacity: 0.5 + 0.5 * j, } } />, ); } } return ( <div aria-hidden className="grid gap-[2px] px-3 pb-2.5 pt-0.5" style={{ gridTemplateColumns: `repeat(${PX_COLS}, minmax(0, 1fr))`, gridAutoRows: '3.5px', }} > {cells} </div> );}export type ToastInput = { title: string; desc?: string; type?: ToastType; duration?: number; action?: { label: string; icon?: LucideIcon; onClick?: () => void };};type ToastData = ToastInput & { id: number; type: ToastType };let list: ToastData[] = [];const subs = new Set<() => void>();let uid = 0;function emit() { subs.forEach((f) => f());}function push(input: ToastInput): number { const id = ++uid; const type = input.type ?? 'info'; const duration = input.duration ?? (type === 'pending' ? 0 : type === 'error' ? 5200 : 4200); list = [{ ...input, id, type, duration }, ...list]; emit(); return id;}function dismiss(id: number) { list = list.filter((t) => t.id !== id); emit();}function update(id: number, patch: Partial<ToastInput>) { list = list.map((t) => { if (t.id !== id) return t; const next = { ...t, ...patch } as ToastData; if (patch.duration === undefined && patch.type && patch.type !== 'pending') { next.duration = patch.type === 'error' ? 5200 : 4200; } return next; }); emit();}export const toast = Object.assign( (input: ToastInput) => push(input), { success: (title: string, o: Partial<ToastInput> = {}) => push({ ...o, title, type: 'success' }), error: (title: string, o: Partial<ToastInput> = {}) => push({ ...o, title, type: 'error' }), info: (title: string, o: Partial<ToastInput> = {}) => push({ ...o, title, type: 'info' }), pending: (title: string, o: Partial<ToastInput> = {}) => push({ ...o, title, type: 'pending', duration: 0 }), dismiss, update, promise<Tn>( run: Promise<Tn>, msg: { loading: string; success: string | ((v: Tn) => string); error: string; desc?: string; }, ) { const id = push({ title: msg.loading, type: 'pending', duration: 0 }); run .then((v) => update(id, { title: typeof msg.success === 'function' ? msg.success(v) : msg.success, desc: msg.desc, type: 'success', }), ) .catch(() => update(id, { title: msg.error, type: 'error' })); return id; }, },);function StatusMark({ type }: { type: ToastType }) { const reduce = useReducedMotion(); const l = LOOK[type]; const Icon = ICON[type]; return ( <span aria-hidden className="grid size-[30px] shrink-0 place-items-center"> <Icon size={type === 'info' ? 17 : 19} strokeWidth={type === 'info' ? 2.6 : 2.5} color={l.accent} className={type === 'pending' && !reduce ? 'tst-spin' : undefined} {...(type === 'info' ? { fill: `${l.accent}33` } : {})} /> </span> );}type Layout = { ty: number; s: number; o: number; contentShown: boolean; z: number;};function ToastCard({ t, layout, paused, reduce, onHeight, onClose,}: { t: ToastData; layout: Layout; paused: boolean; reduce: boolean | null; onHeight: (id: number, h: number) => void; onClose: () => void;}) { const measure = React.useRef<HTMLDivElement>(null); React.useEffect(() => { const el = measure.current; if (!el) return; const report = () => onHeight(t.id, el.offsetHeight); report(); const ro = new ResizeObserver(report); ro.observe(el); return () => ro.disconnect(); }, [t.id, onHeight]); React.useEffect(() => { if (!reduce) return; if (t.type === 'pending' || !t.duration) return; const id = window.setTimeout(onClose, t.duration); return () => window.clearTimeout(id); }, [reduce, t.type, t.duration, onClose]); const timed = t.type !== 'pending' && !!t.duration; return ( <motion.div className="absolute inset-x-0 top-0" style={{ zIndex: layout.z, transformOrigin: '50% 0%' }} initial={ reduce ? { opacity: 0 } : { opacity: 0, y: layout.ty - 26, scale: 0.9, filter: 'blur(8px)' } } animate={ reduce ? { opacity: layout.o } : { opacity: layout.o, y: layout.ty, scale: layout.s, filter: 'blur(0px)', } } exit={ reduce ? { opacity: 0 } : { opacity: 0, y: layout.ty - 12, scale: 0.92, filter: 'blur(6px)' } } transition={{ duration: 0.34, ease: EASE }} > <div ref={measure}> <div className="rounded-[18px] p-[5px]" style={{ background: 'var(--tst-bezel)', boxShadow: 'var(--tst-bezel-shadow)', }} > <div className="relative overflow-hidden rounded-[14px]" style={{ background: 'var(--tst-panel)', boxShadow: 'var(--tst-panel-inset)', }} > <div className="flex items-stretch transition-opacity duration-200" style={{ opacity: layout.contentShown ? 1 : 0, pointerEvents: layout.contentShown ? 'auto' : 'none', }} > <div className="flex min-w-0 flex-1 items-center gap-3 p-3"> <span className="relative"> <AnimatePresence mode="wait" initial={false}> <motion.span key={t.type} initial={reduce ? false : { opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }} transition={{ duration: 0.22, ease: EASE }} className="block" > <StatusMark type={t.type} /> </motion.span> </AnimatePresence> </span> <div className="min-w-0 flex-1"> <p className="truncate text-[13px] font-medium leading-tight" style={{ color: 'var(--tst-text)' }} > {t.title} </p> {t.desc && ( <p className="mt-0.5 truncate text-[11px] leading-tight" style={{ color: 'var(--tst-mute)' }} > {t.desc} </p> )} </div> <button aria-label="Dismiss" onClick={onClose} className={cn( 'mt-px grid size-6 shrink-0 place-items-center rounded-[7px]', 'text-[13px] leading-none outline-none transition-[color,transform] duration-100', 'hover:[color:var(--tst-mute)] active:scale-90 motion-reduce:active:scale-100', )} style={{ color: 'var(--tst-faint)' }} > ✕ </button> </div> {t.action && ( <button onClick={() => { t.action?.onClick?.(); onClose(); }} className={cn( 'flex shrink-0 items-center gap-1.5 px-4 text-[11.5px] font-medium outline-none', 'transition-[background-color,transform] duration-100', 'hover:[background:var(--tst-well)] active:scale-[0.98] motion-reduce:active:scale-100', )} style={{ color: 'var(--tst-text)', borderLeft: '1px solid var(--tst-line)', background: 'transparent', }} > {t.action.icon && ( <t.action.icon size={14} strokeWidth={2.3} color={LOOK[t.type].accent} /> )} {t.action.label} </button> )} </div> {timed && ( <div className="transition-opacity duration-200" style={{ opacity: layout.contentShown ? 1 : 0 }} > <PixelTimer key={t.duration} type={t.type} duration={t.duration!} paused={paused} reduce={reduce} onDone={onClose} /> </div> )} </div> </div> </div> </motion.div> );}const GAP = 12;const PEEK = 15;export type ToasterProps = { max?: number; className?: string;};export function Toaster({ max = 5, className }: ToasterProps) { const data = React.useSyncExternalStore( (cb) => { subs.add(cb); return () => subs.delete(cb); }, () => list, () => list, ); const reduce = useReducedMotion(); const [expanded, setExpanded] = React.useState(false); const [heights, setHeights] = React.useState<Record<number, number>>({}); const onHeight = React.useCallback((id: number, h: number) => { setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h })); }, []); const visible = data.slice(0, max); React.useEffect(() => { if (visible.length <= 1 && expanded) setExpanded(false); }, [visible.length, expanded]); const frontH = heights[visible[0]?.id] ?? 78; let acc = 0; const layouts: Layout[] = visible.map((t, i) => { const h = heights[t.id] ?? 78; const expandedY = acc; acc += h + GAP; const depth = Math.min(i, 2); return { ty: expanded ? expandedY : depth * PEEK, s: expanded ? 1 : 1 - depth * 0.05, o: expanded ? 1 : i < 3 ? 1 : 0, contentShown: expanded || i === 0, z: 100 - i, }; }); const wrapH = expanded ? Math.max(acc - GAP, frontH) : frontH + 2 * PEEK; return ( <div className={cn( 'tst-root pointer-events-none fixed inset-0 z-[100] flex items-start justify-end p-4 sm:p-6', className, )} > <div className="pointer-events-auto relative w-[380px] max-w-[calc(100vw-3rem)]" style={{ height: visible.length ? wrapH : 0, transition: 'height 0.34s cubic-bezier(0.23,1,0.32,1)', }} onMouseEnter={() => visible.length > 1 && setExpanded(true)} onMouseLeave={() => setExpanded(false)} > <AnimatePresence> {visible.map((t, i) => ( <ToastCard key={t.id} t={t} layout={layouts[i]} paused={expanded} reduce={reduce} onHeight={onHeight} onClose={() => dismiss(t.id)} /> ))} </AnimatePresence> </div> </div> );}Add the theme tokens and the pending spin to your global.css.
.tst-root {
--tst-text: #1b1b19;
--tst-mute: #73736d;
--tst-faint: #a6a6a0;
--tst-panel: #ffffff;
--tst-bezel: #f0f0ee;
--tst-well: #f6f6f4;
--tst-line: rgba(27, 27, 25, 0.08);
--tst-px-off: rgba(27, 27, 25, 0.06);
--tst-bezel-shadow: 0 22px 44px -18px rgba(27, 27, 25, 0.22),
0 0 0 1px rgba(27, 27, 25, 0.05);
--tst-panel-inset: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.dark .tst-root {
--tst-text: #f3f3ef;
--tst-mute: #a2a29a;
--tst-faint: #6e6e67;
--tst-panel: #1e1e1b;
--tst-bezel: #161513;
--tst-well: #262622;
--tst-line: rgba(255, 255, 255, 0.07);
--tst-px-off: rgba(245, 245, 245, 0.05);
--tst-bezel-shadow: 0 22px 44px -18px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(255, 255, 255, 0.05);
--tst-panel-inset: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
@keyframes tst-spin {
to {
transform: rotate(360deg);
}
}
.tst-spin {
animation: tst-spin 0.9s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.tst-spin {
animation: none;
}
}Usage
Mount <Toaster /> once near the root of your app, then call toast from
anywhere.
import { toast, Toaster } from '@/components/ui/toast';
import { GitCompare } from 'lucide-react';
export default function Example() {
return (
<>
<button
onClick={() =>
toast.success('Changes saved', {
desc: 'Atlas redesign · 12 files',
action: { label: 'View', icon: GitCompare },
})
}
>
Save
</button>
<Toaster />
</>
);
}toast.promise choreographs a pending toast that resolves in place:
toast.promise(deploy(), {
loading: 'Deploying to production',
success: 'Deployed to production',
error: 'Deploy failed',
desc: 'atlas-web · commit 8f2a1c',
});API
| Call | Description |
|---|---|
toast(input) | Push a raw toast (title, desc?, type?, duration?, action?). |
toast.success(title, o) | Green success toast. |
toast.error(title, o) | Red error toast, held a little longer. |
toast.info(title, o) | Blue info toast. |
toast.pending(title, o) | Spinning toast that sticks until updated. |
toast.promise(p, msg) | Pending toast that resolves or rejects in place. |
toast.dismiss(id) | Remove a toast by id. |
toast.update(id, patch) | Patch a live toast (used to resolve pending ones). |
Props
<Toaster /> accepts:
| Prop | Type | Default | Description |
|---|---|---|---|
max | number | 5 | How many toasts render in the pile at once. |
className | string | - | Forwarded to the fixed overlay wrapper. |