Overview
Mount <Toaster /> once and call toast() from anywhere. New toasts rise out of a soft blur from the screen edge and stack into a deck; hover or focus fans the deck out to full height and pauses every timer, with a thin progress line showing time left. Swipe a toast toward the edge or sideways to throw it away. Toasts can carry an inline action such as Undo, and toast.promise follows async work from a spinner to a success or error in the same toast, swapping the icon and text in place. Alt+T focuses the newest toast and Esc dismisses it.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/toast-stack.jsonInstall dependencies:
$ pnpm add motion lucide-reactAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, {4 useCallback,5 useEffect,6 useLayoutEffect,7 useRef,8 useState,9 useSyncExternalStore,10} from "react";11import { createPortal } from "react-dom";12import {13 AnimatePresence,14 animate,15 motion,16 useMotionValue,17 useReducedMotion,18 useTransform,19} from "motion/react";20import {21 CircleAlert,22 CircleCheck,23 LoaderCircle,24 TriangleAlert,25 X,26} from "lucide-react";27import { cn } from "@/lib/utils";28 29/* ---------------------------------- store --------------------------------- */30 31export type ToastTone = "default" | "success" | "error" | "warning" | "loading";32 33export type ToastAction = {34 label: string;35 onClick: () => void;36};37 38export type ToastInput = {39 title: string;40 description?: string;41 tone?: ToastTone;42 /** Milliseconds before auto-dismiss. `0` keeps the toast until dismissed. */43 duration?: number;44 /** A single inline action, e.g. Undo. Clicking it also dismisses the toast. */45 action?: ToastAction;46 /** Replaces the tone icon. */47 icon?: React.ReactNode;48 /** Reuse an id to update a toast in place instead of stacking a new one. */49 id?: string;50};51 52type ToastRecord = Omit<ToastInput, "id"> & {53 id: string;54 duration: number;55 /** Bumps on every update so timers restart and content cross-fades. */56 version: number;57};58 59type Listener = () => void;60 61let toasts: ToastRecord[] = [];62let limitCap = 3;63const listeners = new Set<Listener>();64const EMPTY: ToastRecord[] = [];65 66function emit() {67 listeners.forEach((l) => l());68}69 70function subscribe(listener: Listener) {71 listeners.add(listener);72 return () => {73 listeners.delete(listener);74 };75}76 77function makeId() {78 if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();79 return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;80}81 82function defaultDuration(tone: ToastTone | undefined) {83 if (tone === "loading") return 0;84 if (tone === "error") return 6000;85 return 4000;86}87 88function upsert(input: ToastInput): string {89 const existing = input.id ? toasts.find((t) => t.id === input.id) : undefined;90 if (existing) {91 const tone = input.tone ?? existing.tone;92 const record: ToastRecord = {93 ...existing,94 ...input,95 id: existing.id,96 tone,97 duration: input.duration ?? defaultDuration(tone),98 version: existing.version + 1,99 };100 toasts = toasts.map((t) => (t.id === record.id ? record : t));101 } else {102 const record: ToastRecord = {103 ...input,104 id: input.id ?? makeId(),105 duration: input.duration ?? defaultDuration(input.tone),106 version: 0,107 };108 toasts = [record, ...toasts].slice(0, limitCap);109 }110 emit();111 return input.id ?? toasts[0].id;112}113 114/** Remove one toast by id, or every toast when called without one. */115export function dismiss(id?: string) {116 toasts = id ? toasts.filter((t) => t.id !== id) : [];117 emit();118}119 120type Message<T> = string | ((value: T) => string);121const resolveMessage = <T,>(m: Message<T>, v: T) => (typeof m === "function" ? m(v) : m);122 123type ToastFn = {124 (input: ToastInput): string;125 success: (title: string, description?: string) => string;126 error: (title: string, description?: string) => string;127 warning: (title: string, description?: string) => string;128 message: (title: string, description?: string) => string;129 loading: (title: string, description?: string) => string;130 /** One toast that follows a promise from loading to success or error. */131 promise: <T>(132 promise: Promise<T> | (() => Promise<T>),133 messages: { loading: string; success: Message<T>; error: Message<unknown> },134 ) => string;135 update: (id: string, input: Partial<ToastInput>) => string;136 dismiss: (id?: string) => void;137};138 139export const toast: ToastFn = Object.assign((input: ToastInput) => upsert(input), {140 success: (title: string, description?: string) => upsert({ title, description, tone: "success" }),141 error: (title: string, description?: string) => upsert({ title, description, tone: "error" }),142 warning: (title: string, description?: string) => upsert({ title, description, tone: "warning" }),143 message: (title: string, description?: string) => upsert({ title, description, tone: "default" }),144 loading: (title: string, description?: string) => upsert({ title, description, tone: "loading" }),145 promise: <T,>(146 promise: Promise<T> | (() => Promise<T>),147 messages: { loading: string; success: Message<T>; error: Message<unknown> },148 ) => {149 const id = upsert({ title: messages.loading, tone: "loading" });150 const run = typeof promise === "function" ? promise() : promise;151 run.then(152 (value) => {153 if (toasts.some((t) => t.id === id))154 upsert({ id, title: resolveMessage(messages.success, value), tone: "success", description: undefined });155 },156 (error: unknown) => {157 if (toasts.some((t) => t.id === id))158 upsert({ id, title: resolveMessage(messages.error, error), tone: "error", description: undefined });159 },160 );161 return id;162 },163 update: (id: string, input: Partial<ToastInput>) => {164 const current = toasts.find((t) => t.id === id);165 return current ? upsert({ ...current, ...input, id }) : id;166 },167 dismiss,168});169 170/* --------------------------------- toaster -------------------------------- */171 172const GAP = 10;173const PEEK = 12;174const FALLBACK_H = 60;175const SPRING = { type: "spring" as const, bounce: 0, duration: 0.38 };176 177export type ToasterProps = {178 /** Most toasts kept at once; the oldest leaves when a new one arrives. */179 limit?: number;180 position?: "bottom-center" | "bottom-right" | "top-center" | "top-right";181 className?: string;182};183 184export function Toaster({ limit = 3, position = "bottom-center", className }: ToasterProps) {185 const reduce = !!useReducedMotion();186 const items = useSyncExternalStore(subscribe, () => toasts, () => EMPTY);187 const [mounted, setMounted] = useState(false);188 const [hovered, setHovered] = useState(false);189 const [focused, setFocused] = useState(false);190 const [heights, setHeights] = useState<Record<string, number>>({});191 const listRef = useRef<HTMLOListElement>(null);192 193 const isTop = position.startsWith("top");194 // +1 means "toward the screen edge" is down (bottom toasters), -1 is up.195 const edge = isTop ? -1 : 1;196 const expanded = (hovered || focused) && items.length > 0;197 198 useEffect(() => setMounted(true), []);199 200 useEffect(() => {201 limitCap = limit;202 if (toasts.length > limit) {203 toasts = toasts.slice(0, limit);204 emit();205 }206 }, [limit]);207 208 // Alt+T jumps to the newest toast, so keyboard users can reach actions.209 useEffect(() => {210 const onKey = (e: KeyboardEvent) => {211 if (e.altKey && e.code === "KeyT") {212 const first = listRef.current?.querySelector<HTMLElement>("[data-toast]");213 if (first) {214 e.preventDefault();215 first.focus();216 }217 }218 };219 window.addEventListener("keydown", onKey);220 return () => window.removeEventListener("keydown", onKey);221 }, []);222 223 // Collapse when the stack empties, so the next toast arrives stacked.224 useEffect(() => {225 if (items.length === 0) {226 setHovered(false);227 setFocused(false);228 }229 }, [items.length]);230 231 const onHeight = useCallback((id: string, h: number) => {232 setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));233 }, []);234 235 if (!mounted) return null;236 237 const heightOf = (id: string) => heights[id] ?? FALLBACK_H;238 const frontH = items[0] ? heightOf(items[0].id) : 0;239 const offsets: number[] = [];240 let acc = 0;241 items.forEach((t, i) => {242 offsets.push(expanded ? acc : i * PEEK);243 acc += heightOf(t.id) + GAP;244 });245 const stackH =246 items.length === 0247 ? 0248 : expanded249 ? acc - GAP250 : frontH + PEEK * (Math.min(items.length, 3) - 1);251 252 return createPortal(253 <section254 aria-label="Notifications (Alt+T)"255 className={cn(256 "pointer-events-none fixed inset-x-0 z-80 flex px-4 sm:px-6",257 isTop258 ? "top-0 pt-[max(1rem,env(safe-area-inset-top))] sm:pt-6"259 : "bottom-0 pb-[max(1rem,env(safe-area-inset-bottom))] sm:pb-6",260 position.endsWith("right") ? "justify-center sm:justify-end" : "justify-center",261 className,262 )}263 >264 <motion.ol265 ref={listRef}266 aria-live="polite"267 aria-relevant="additions text"268 initial={false}269 animate={{ height: stackH }}270 transition={reduce ? { duration: 0 } : SPRING}271 onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}272 onPointerLeave={(e) => e.pointerType === "mouse" && setHovered(false)}273 // Touch has no hover: a tap on the stack fans it out.274 onPointerDown={(e) => e.pointerType !== "mouse" && setHovered(true)}275 onFocus={() => setFocused(true)}276 onBlur={(e) => {277 if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);278 }}279 className={cn(280 "relative m-0 w-full max-w-[360px] list-none p-0",281 items.length > 0 ? "pointer-events-auto" : "pointer-events-none",282 )}283 >284 <AnimatePresence initial={false}>285 {items.map((t, index) => (286 <ToastCard287 key={t.id}288 toast={t}289 index={index}290 total={items.length}291 offset={offsets[index]}292 frontH={frontH}293 expanded={expanded}294 paused={expanded}295 edge={edge}296 reduce={reduce}297 onHeight={onHeight}298 />299 ))}300 </AnimatePresence>301 </motion.ol>302 </section>,303 document.body,304 );305}306 307/* ---------------------------------- card ---------------------------------- */308 309const TONE_ICON: Record<Exclude<ToastTone, "default">, React.ReactNode> = {310 success: <CircleCheck className="text-emerald-500" />,311 error: <CircleAlert className="text-red-500" />,312 warning: <TriangleAlert className="text-amber-500" />,313 loading: <LoaderCircle className="animate-spin text-(--color-neutral)" />,314};315 316const TONE_BAR: Record<ToastTone, string> = {317 default: "bg-(--color-ink)/25",318 success: "bg-emerald-500/60",319 error: "bg-red-500/60",320 warning: "bg-amber-500/60",321 loading: "bg-transparent",322};323 324function ToastCard({325 toast: t,326 index,327 total,328 offset,329 frontH,330 expanded,331 paused,332 edge,333 reduce,334 onHeight,335}: {336 toast: ToastRecord;337 index: number;338 total: number;339 offset: number;340 frontH: number;341 expanded: boolean;342 paused: boolean;343 edge: 1 | -1;344 reduce: boolean;345 onHeight: (id: string, h: number) => void;346}) {347 const contentRef = useRef<HTMLDivElement>(null);348 const sx = useMotionValue(0);349 const sy = useMotionValue(0);350 const progress = useMotionValue(1);351 const fade = useTransform(() => 1 - Math.min(1, Math.hypot(sx.get(), sy.get()) / 220));352 const [dragging, setDragging] = useState(false);353 const gesture = useRef<{354 x: number;355 y: number;356 axis: "x" | "y" | null;357 samples: { t: number; x: number; y: number }[];358 } | null>(null);359 const remaining = useRef(t.duration);360 const close = useCallback(() => dismiss(t.id), [t.id]);361 362 const isBack = index > 0 && !expanded;363 const interactive = index === 0 || expanded;364 365 // Natural height of the content, independent of the collapsed clip.366 useLayoutEffect(() => {367 const el = contentRef.current;368 if (!el) return;369 const ro = new ResizeObserver(() => onHeight(t.id, el.offsetHeight));370 ro.observe(el);371 onHeight(t.id, el.offsetHeight);372 return () => ro.disconnect();373 }, [onHeight, t.id]);374 375 // Auto-dismiss timer. Restarts when the toast is updated (e.g. a promise376 // settles) and holds while the stack is expanded or being swiped.377 useEffect(() => {378 remaining.current = t.duration;379 progress.set(1);380 }, [t.version, t.duration, progress]);381 382 useEffect(() => {383 if (t.duration <= 0) return;384 let raf = 0;385 let last = performance.now();386 const loop = (now: number) => {387 const dt = now - last;388 last = now;389 if (!paused && !dragging) {390 remaining.current -= dt;391 progress.set(Math.max(0, remaining.current / t.duration));392 if (remaining.current <= 0) return close();393 }394 raf = requestAnimationFrame(loop);395 };396 raf = requestAnimationFrame(loop);397 return () => cancelAnimationFrame(raf);398 }, [paused, dragging, t.duration, t.version, close, progress]);399 400 /* ------------------------------- swipe -------------------------------- */401 402 const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {403 if (!interactive || e.button !== 0 || (e.target as Element).closest("button")) return;404 gesture.current = {405 x: e.clientX,406 y: e.clientY,407 axis: null,408 samples: [{ t: performance.now(), x: e.clientX, y: e.clientY }],409 };410 };411 412 const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {413 const g = gesture.current;414 if (!g) return;415 const dx = e.clientX - g.x;416 const dy = e.clientY - g.y;417 if (!g.axis) {418 if (Math.hypot(dx, dy) < 6) return;419 g.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";420 try {421 e.currentTarget.setPointerCapture(e.pointerId);422 } catch {423 /* Pointer already released. */424 }425 setDragging(true);426 }427 g.samples.push({ t: performance.now(), x: e.clientX, y: e.clientY });428 if (g.samples.length > 5) g.samples.shift();429 if (g.axis === "x") sx.set(dx);430 // Toward the screen edge moves freely; away from it resists.431 else sy.set(dy * edge > 0 ? dy : dy * 0.18);432 };433 434 const onPointerUp = () => {435 const g = gesture.current;436 gesture.current = null;437 if (!g?.axis) return;438 setDragging(false);439 const a = g.samples[0];440 const b = g.samples[g.samples.length - 1];441 const dt = Math.max(16, b.t - a.t);442 const value = g.axis === "x" ? sx : sy;443 const v = ((g.axis === "x" ? b.x - a.x : b.y - a.y) / dt) * 1000;444 const d = value.get();445 const out =446 g.axis === "x"447 ? Math.abs(d) > 70 || Math.abs(v) > 450448 : d * edge > 40 || v * edge > 450;449 if (out) {450 const dir = g.axis === "x" ? Math.sign(d || v) : edge;451 animate(value, dir * 420, { type: "spring", bounce: 0, duration: 0.35, velocity: v });452 close();453 } else {454 animate(value, 0, { type: "spring", bounce: 0.2, duration: 0.4, velocity: v });455 }456 };457 458 /* ------------------------------- render ------------------------------- */459 460 const icon = t.icon ?? (t.tone && t.tone !== "default" ? TONE_ICON[t.tone] : null);461 const enterY = reduce ? 0 : edge * 28;462 463 return (464 <motion.li465 data-toast=""466 tabIndex={0}467 aria-roledescription="notification"468 onKeyDown={(e) => {469 if (e.key === "Escape") {470 e.stopPropagation();471 close();472 }473 }}474 initial={{475 y: enterY,476 opacity: 0,477 scale: reduce ? 1 : 0.96,478 filter: reduce ? "blur(0px)" : "blur(6px)",479 }}480 animate={{481 y: -edge * offset,482 opacity: index >= 3 && !expanded ? 0 : 1,483 scale: isBack && !reduce ? 1 - Math.min(index, 3) * 0.05 : 1,484 filter: "blur(0px)",485 height: isBack ? frontH : "auto",486 }}487 exit={{488 opacity: 0,489 scale: reduce ? 1 : 0.94,490 filter: reduce ? "blur(0px)" : "blur(6px)",491 transition: { duration: 0.22, ease: [0.23, 1, 0.32, 1] },492 }}493 transition={reduce ? { duration: 0.15 } : SPRING}494 style={{495 zIndex: total - index,496 transformOrigin: edge === 1 ? "50% 0%" : "50% 100%",497 }}498 className={cn(499 "absolute inset-x-0 overflow-hidden rounded-[16px] outline-none",500 edge === 1 ? "bottom-0" : "top-0",501 "bg-(--color-paper)/92 backdrop-blur-xl backdrop-saturate-150",502 "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.05),0_12px_32px_-8px_rgba(0,0,0,0.18)]",503 "dark:shadow-[inset_0_0_0_1px_var(--color-rule),0_12px_32px_-8px_rgba(0,0,0,0.6)]",504 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",505 !interactive && "pointer-events-none",506 )}507 >508 <motion.div509 ref={contentRef}510 style={{ x: sx, y: sy, opacity: fade }}511 onPointerDown={onPointerDown}512 onPointerMove={onPointerMove}513 onPointerUp={onPointerUp}514 onPointerCancel={onPointerUp}515 className={cn(516 "group/toast relative flex touch-none items-start gap-3 py-3 pr-2.5 pl-3.5 select-none",517 dragging ? "cursor-grabbing" : "cursor-default",518 )}519 >520 {icon ? (521 <motion.span522 animate={{ opacity: isBack ? 0 : 1 }}523 className="relative mt-px flex size-[18px] shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.9]"524 >525 <AnimatePresence initial={false} mode="popLayout">526 <motion.span527 key={t.tone ?? "custom"}528 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(4px)" }}529 animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}530 exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(4px)" }}531 transition={{ type: "spring", bounce: 0.35, duration: 0.45 }}532 className="flex size-full"533 aria-hidden534 >535 {icon}536 </motion.span>537 </AnimatePresence>538 </motion.span>539 ) : null}540 541 <motion.div542 key={t.version}543 initial={t.version === 0 || reduce ? false : { opacity: 0, y: 4, filter: "blur(3px)" }}544 animate={{ opacity: isBack ? 0 : 1, y: 0, filter: "blur(0px)" }}545 transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}546 className="min-w-0 flex-1 pt-px"547 >548 <div className="text-[13.5px] leading-snug font-medium text-(--color-ink)">{t.title}</div>549 {t.description ? (550 <div className="mt-0.5 text-[12.5px] leading-relaxed text-(--color-neutral)">551 {t.description}552 </div>553 ) : null}554 </motion.div>555 556 {t.action ? (557 <motion.button558 type="button"559 animate={{ opacity: isBack ? 0 : 1 }}560 onClick={() => {561 t.action?.onClick();562 close();563 }}564 className={cn(565 "mt-px h-7 shrink-0 rounded-full bg-(--color-ink) px-3 text-[12px] font-medium text-(--color-paper)",566 "transition-transform duration-150 outline-none active:scale-[0.96]",567 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",568 )}569 >570 {t.action.label}571 </motion.button>572 ) : null}573 574 <motion.button575 type="button"576 aria-label="Dismiss notification"577 animate={{ opacity: isBack ? 0 : 1 }}578 onClick={close}579 className={cn(580 "relative -my-0.5 flex size-8 shrink-0 items-center justify-center rounded-full outline-none",581 "text-(--color-neutral) transition-[color,background-color,opacity,transform] duration-150",582 "hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-[0.92]",583 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",584 "[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/toast:opacity-100 [@media(hover:hover)]:focus-visible:opacity-100",585 )}586 >587 <X className="size-3.5" strokeWidth={2} aria-hidden />588 </motion.button>589 590 {t.duration > 0 ? (591 <motion.span592 aria-hidden593 animate={{ opacity: isBack ? 0 : 1 }}594 style={{ scaleX: progress }}595 className={cn(596 "pointer-events-none absolute inset-x-3.5 bottom-0 h-[2px] origin-left rounded-full",597 TONE_BAR[t.tone ?? "default"],598 )}599 />600 ) : null}601 </motion.div>602 </motion.li>603 );604}605 606/** @deprecated Mount `<Toaster />` once and call `toast()` instead. */607export function ToastStackProvider({608 children,609 limit = 3,610 className,611}: {612 children?: React.ReactNode;613 limit?: number;614 className?: string;615}) {616 return (617 <>618 {children}619 <Toaster limit={limit} className={className} />620 </>621 );622}623 624/** @deprecated Use `toast` from this module. */625export function useToastStack() {626 return { toast: (input: ToastInput) => upsert(input), dismiss };627}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { Toaster, toast } from "@/components/ui/toast-stack";2 3// Once in your layout4<Toaster position="bottom-center" />5 6// Anywhere, no hook required7toast.success("Invite sent", "Mira can now open the workspace.");8toast.error("Deploy failed", "Build #1842 exited with code 1.");9 10toast({11 title: "Moved to trash",12 action: { label: "Undo", onClick: restore },13});14 15toast.promise(saveDraft(), {16 loading: "Saving…",17 success: "Draft saved",18 error: (e) => `Couldn't save: ${String(e)}`,19});
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| limit | number | No | 3 | Most toasts kept at once. The oldest leaves when a new one arrives. |
| position | "bottom-center" | "bottom-right" | "top-center" | "top-right" | No | "bottom-center" | Viewport placement. Toasts enter from, and swipe out toward, that edge. |
| toast(input) | (input: ToastInput) => string | No | — | Show a toast and return its id. Input: title, description?, tone?, duration? (0 = sticky), action?, icon?, id? (reuse to update in place). |
| toast.success / error / warning / message / loading | (title, description?) => string | No | — | Tone shortcuts. Errors stay 6s, loading stays until updated, everything else 4s. |
| toast.promise | (promise, { loading, success, error }) => string | No | — | One toast that goes from loading to success or error. success and error can be functions of the result. |
| toast.update / toast.dismiss | (id, input) => string · (id?) => void | No | — | Change a toast in place (its timer restarts), or dismiss one or all. |
Best Practices
- Mount one
<Toaster />per app;toast()works from event handlers, stores and async code. - Keep titles to one line and put detail in
description; the collapsed deck uses the front toast's height. - Use an
actionfor reversible operations instead of a confirm dialog. - Use
toast.promiserather than separate loading and result toasts, so the user follows one item. - Timers pause while the deck is hovered or focused and while the tab is hidden; reduced motion swaps the blur and scale for a plain fade.