Overview
A craft toast system inspired by Sonner’s principles: mount <Toaster /> once, call toast() from anywhere, enter and exit the same bottom edge, swipe down to dismiss, pause timers on hover / tab hidden, and expand the stack on hover.
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 useRef,7 useState,8 useSyncExternalStore,9} from "react";10import { createPortal } from "react-dom";11import {12 AnimatePresence,13 motion,14 useMotionValue,15 useReducedMotion,16 animate,17} from "motion/react";18import { X } from "lucide-react";19import { cn } from "@/lib/utils";20 21export type ToastTone = "default" | "success" | "error";22 23export type ToastInput = {24 title: string;25 description?: string;26 tone?: ToastTone;27 duration?: number;28};29 30type ToastRecord = ToastInput & {31 id: string;32 duration: number;33 remaining: number;34 createdAt: number;35};36 37type Listener = () => void;38 39let toasts: ToastRecord[] = [];40let limitCap = 3;41const listeners = new Set<Listener>();42 43function emit() {44 listeners.forEach((l) => l());45}46 47function subscribe(listener: Listener) {48 listeners.add(listener);49 return () => listeners.delete(listener);50}51 52function getSnapshot() {53 return toasts;54}55 56const EMPTY_TOASTS: ToastRecord[] = [];57 58function getServerSnapshot(): ToastRecord[] {59 return EMPTY_TOASTS;60}61 62function makeId() {63 if (typeof crypto !== "undefined" && crypto.randomUUID) {64 return crypto.randomUUID();65 }66 return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;67}68 69function pushToast(input: ToastInput | string, tone?: ToastTone) {70 const normalized: ToastInput =71 typeof input === "string" ? { title: input, tone } : { ...input, tone: input.tone ?? tone };72 const duration = normalized.duration ?? 4000;73 const record: ToastRecord = {74 ...normalized,75 id: makeId(),76 duration,77 remaining: duration,78 createdAt: Date.now(),79 };80 toasts = [record, ...toasts].slice(0, limitCap);81 emit();82 return record.id;83}84 85function removeToast(id: string) {86 toasts = toasts.filter((t) => t.id !== id);87 emit();88}89 90export function dismiss(id: string) {91 removeToast(id);92}93 94type ToastFn = {95 (input: ToastInput): string;96 success: (title: string, description?: string) => string;97 error: (title: string, description?: string) => string;98 message: (title: string, description?: string) => string;99};100 101export const toast: ToastFn = Object.assign(102 (input: ToastInput) => pushToast(input),103 {104 success: (title: string, description?: string) =>105 pushToast({ title, description, tone: "success" }),106 error: (title: string, description?: string) =>107 pushToast({ title, description, tone: "error" }),108 message: (title: string, description?: string) =>109 pushToast({ title, description, tone: "default" }),110 },111);112 113const STACK_GAP = 14;114const TOAST_H = 64;115const DISMISS_DY = 48;116const DISMISS_V = 0.11;117 118function readToastCloseMs() {119 const v = parseFloat(120 getComputedStyle(document.documentElement).getPropertyValue("--toast-close"),121 );122 return Number.isFinite(v) ? v : 250;123}124 125export type ToasterProps = {126 limit?: number;127 position?: "bottom-center" | "bottom-right";128 className?: string;129};130 131export function Toaster({132 limit = 3,133 position = "bottom-center",134 className,135}: ToasterProps) {136 const reduce = useReducedMotion();137 const [mounted, setMounted] = useState(false);138 const [expanded, setExpanded] = useState(false);139 const [paused, setPaused] = useState(false);140 const items = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);141 142 useEffect(() => {143 setMounted(true);144 }, []);145 146 useEffect(() => {147 limitCap = limit;148 if (toasts.length > limit) {149 toasts = toasts.slice(0, limit);150 emit();151 }152 }, [limit]);153 154 useEffect(() => {155 const onVis = () => {156 if (document.visibilityState === "hidden") setPaused(true);157 else setPaused(false);158 };159 document.addEventListener("visibilitychange", onVis);160 return () => document.removeEventListener("visibilitychange", onVis);161 }, []);162 163 if (!mounted) return null;164 165 return createPortal(166 <div167 className={cn(168 "pointer-events-none fixed inset-x-0 bottom-0 z-80 flex p-4 sm:p-6",169 position === "bottom-right"170 ? "justify-end"171 : "justify-center sm:justify-center",172 className,173 )}174 aria-live="polite"175 aria-relevant="additions removals"176 >177 <div178 className={cn(179 "relative w-full max-w-[360px]",180 // Empty host must not steal clicks from page chrome (e.g. bottom nav).181 items.length > 0 ? "pointer-events-auto" : "pointer-events-none",182 )}183 style={{184 minHeight:185 items.length === 0186 ? 0187 : Math.max(188 TOAST_H + 24,189 expanded190 ? items.length * (TOAST_H + STACK_GAP)191 : TOAST_H + Math.min(items.length - 1, 2) * 10,192 ),193 }}194 onMouseEnter={() => {195 if (items.length === 0) return;196 setExpanded(true);197 setPaused(true);198 }}199 onMouseLeave={() => {200 setExpanded(false);201 if (document.visibilityState !== "hidden") setPaused(false);202 }}203 onFocusCapture={() => {204 if (items.length === 0) return;205 setExpanded(true);206 setPaused(true);207 }}208 onBlurCapture={(e) => {209 if (!e.currentTarget.contains(e.relatedTarget as Node)) {210 setExpanded(false);211 if (document.visibilityState !== "hidden") setPaused(false);212 }213 }}214 >215 <AnimatePresence initial={false}>216 {items.map((t, index) => (217 <ToastCard218 key={t.id}219 toast={t}220 index={index}221 total={items.length}222 expanded={expanded}223 paused={paused}224 reduce={!!reduce}225 onDismiss={() => removeToast(t.id)}226 />227 ))}228 </AnimatePresence>229 </div>230 </div>,231 document.body,232 );233}234 235function ToastCard({236 toast: t,237 index,238 total,239 expanded,240 paused,241 reduce,242 onDismiss,243}: {244 toast: ToastRecord;245 index: number;246 total: number;247 expanded: boolean;248 paused: boolean;249 reduce: boolean;250 onDismiss: () => void;251}) {252 const y = useMotionValue(0);253 const [dragging, setDragging] = useState(false);254 const [open, setOpen] = useState(false);255 const [exiting, setExiting] = useState(false);256 const samples = useRef<{ t: number; y: number }[]>([]);257 const remainingRef = useRef(t.remaining);258 const lastTick = useRef<number | null>(null);259 const exitTimer = useRef<number | null>(null);260 261 useEffect(() => {262 remainingRef.current = t.remaining;263 }, [t.id, t.remaining]);264 265 // Enter via .t-toast.is-open266 useEffect(() => {267 const id = requestAnimationFrame(() => setOpen(true));268 return () => cancelAnimationFrame(id);269 }, []);270 271 const beginExit = useCallback(() => {272 if (exiting) return;273 setExiting(true);274 setOpen(false);275 if (reduce) {276 onDismiss();277 return;278 }279 const ms = readToastCloseMs();280 exitTimer.current = window.setTimeout(() => {281 exitTimer.current = null;282 onDismiss();283 }, ms + 20);284 }, [exiting, onDismiss, reduce]);285 286 useEffect(() => {287 return () => {288 if (exitTimer.current) window.clearTimeout(exitTimer.current);289 };290 }, []);291 292 useEffect(() => {293 if (t.duration <= 0) return;294 let raf = 0;295 296 const loop = (now: number) => {297 if (paused || dragging || exiting) {298 lastTick.current = now;299 raf = requestAnimationFrame(loop);300 return;301 }302 if (lastTick.current == null) lastTick.current = now;303 const delta = now - lastTick.current;304 lastTick.current = now;305 remainingRef.current -= delta;306 if (remainingRef.current <= 0) {307 beginExit();308 return;309 }310 raf = requestAnimationFrame(loop);311 };312 313 raf = requestAnimationFrame(loop);314 return () => cancelAnimationFrame(raf);315 }, [paused, dragging, exiting, beginExit, t.duration, t.id]);316 317 const front = index === 0;318 const offsetY = expanded ? -index * (TOAST_H + STACK_GAP) : -index * 12;319 const scale = expanded ? 1 : 1 - Math.min(index, 2) * 0.05;320 321 const settle = useCallback(322 (vy: number) => {323 animate(y, 0, {324 type: "spring",325 bounce: 0,326 duration: 0.35,327 velocity: vy,328 });329 },330 [y],331 );332 333 const flingOut = useCallback(334 (vy: number) => {335 const target =336 typeof window !== "undefined" ? window.innerHeight : 640;337 animate(y, target, {338 type: "spring",339 bounce: 0,340 duration: 0.38,341 velocity: vy,342 onComplete: onDismiss,343 });344 },345 [y, onDismiss],346 );347 348 const toneBorder =349 t.tone === "success"350 ? "border-emerald-600/30"351 : t.tone === "error"352 ? "border-red-600/30"353 : "border-(--color-rule)";354 355 return (356 <motion.div357 layout358 // Stack offsets only — enter/exit owned by .t-toast359 animate={{360 y: offsetY,361 scale,362 zIndex: total - index,363 opacity: front || expanded ? 1 : Math.max(0.55, 1 - index * 0.2),364 }}365 exit={{ opacity: 0, transition: { duration: 0.01 } }}366 transition={367 reduce368 ? { duration: 0.15 }369 : { type: "spring" as const, bounce: 0, duration: 0.3 }370 }371 className={cn(372 "absolute inset-x-0 bottom-0 w-full",373 !front && !expanded && "pointer-events-none",374 )}375 >376 <div377 className={cn(378 "t-toast rounded-lg border bg-(--color-paper)/95",379 "shadow-[0_12px_40px_rgba(0,0,0,0.12)] backdrop-blur-xl",380 "dark:shadow-[0_12px_40px_rgba(0,0,0,0.45)]",381 toneBorder,382 open && "is-open",383 )}384 >385 <motion.div386 style={{ y }}387 className="touch-none select-none px-4 py-3"388 onPointerDown={(e) => {389 if (reduce || !front || exiting) return;390 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);391 setDragging(true);392 y.set(0);393 samples.current = [{ t: performance.now(), y: e.clientY }];394 }}395 onPointerMove={(e) => {396 if (!dragging || reduce) return;397 const first = samples.current[0];398 if (!first) return;399 const dy = Math.max(0, e.clientY - first.y);400 y.set(dy);401 samples.current.push({ t: performance.now(), y: e.clientY });402 if (samples.current.length > 6) samples.current.shift();403 }}404 onPointerUp={(e) => {405 if (!dragging || reduce) return;406 setDragging(false);407 try {408 (e.currentTarget as HTMLElement).releasePointerCapture(409 e.pointerId,410 );411 } catch {412 /* noop */413 }414 const arr = samples.current;415 const last = arr[arr.length - 1];416 const prev = arr[Math.max(0, arr.length - 3)];417 const dt = Math.max(16, (last?.t ?? 0) - (prev?.t ?? 0));418 const vy = ((last?.y ?? 0) - (prev?.y ?? 0)) / dt;419 const dy = y.get();420 if (dy > DISMISS_DY || vy > DISMISS_V) {421 flingOut(vy * 1000);422 } else {423 settle(vy * 1000);424 }425 }}426 >427 <div className="flex items-start gap-3">428 <div className="min-w-0 flex-1">429 <p className="text-sm font-medium text-(--color-ink)">430 {t.title}431 </p>432 {t.description ? (433 <p className="mt-0.5 text-xs leading-relaxed text-(--color-ink-muted)">434 {t.description}435 </p>436 ) : null}437 </div>438 <button439 type="button"440 aria-label="Dismiss"441 onClick={beginExit}442 className={cn(443 "flex size-10 shrink-0 items-center justify-center rounded-sm",444 "text-(--color-ink-muted) outline-none",445 "transition-[transform,color] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",446 "hover:text-(--color-ink) active:scale-[0.96]",447 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",448 )}449 >450 <X className="size-3.5 stroke-[1.5]" />451 </button>452 </div>453 </motion.div>454 </div>455 </motion.div>456 );457}458 459/** @deprecated Use `<Toaster />` + `toast()` instead */460export function ToastStackProvider({461 children,462 limit = 3,463 className,464}: {465 children?: React.ReactNode;466 limit?: number;467 className?: string;468}) {469 return (470 <>471 {children}472 <Toaster limit={limit} className={className} />473 </>474 );475}476 477/** @deprecated Use `toast` from this module */478export function useToastStack() {479 return {480 toast: (input: ToastInput) => pushToast(input),481 dismiss,482 };483}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { Toaster, toast } from "@/components/ui/toast-stack";2 3// Once in your layout / page4<Toaster position="bottom-center" limit={3} />5 6// Anywhere — no hook required7toast.success("Invite sent", "Mira will get access.");8toast.error("Deploy failed");9toast({ title: "Saved", tone: "default" });
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| limit | number | No | 3 | Max visible toasts in the stack. |
| position | "bottom-center" | "bottom-right" | No | "bottom-center" | Viewport placement (matches enter edge). |
| toast() | (input: ToastInput) => string | No | — | Push a toast; returns its id. Also toast.success / toast.error / toast.message. |
| dismiss() | (id: string) => void | No | — | Remove a toast by id. |
Best Practices
- Enter and exit from the same edge — swipe dismisses downward toward that edge.
- Timers pause on hover, focus, and
document.hidden— resume when idle again. - Enter uses a ≤300ms spring (
bounce: 0); dismiss control is ≥40×40. - Prefer
toast.success/toast.errorhelpers for tone defaults.