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 56function getServerSnapshot(): ToastRecord[] {57 return [];58}59 60function makeId() {61 if (typeof crypto !== "undefined" && crypto.randomUUID) {62 return crypto.randomUUID();63 }64 return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;65}66 67function pushToast(input: ToastInput | string, tone?: ToastTone) {68 const normalized: ToastInput =69 typeof input === "string" ? { title: input, tone } : { ...input, tone: input.tone ?? tone };70 const duration = normalized.duration ?? 4000;71 const record: ToastRecord = {72 ...normalized,73 id: makeId(),74 duration,75 remaining: duration,76 createdAt: Date.now(),77 };78 toasts = [record, ...toasts].slice(0, limitCap);79 emit();80 return record.id;81}82 83function removeToast(id: string) {84 toasts = toasts.filter((t) => t.id !== id);85 emit();86}87 88export function dismiss(id: string) {89 removeToast(id);90}91 92type ToastFn = {93 (input: ToastInput): string;94 success: (title: string, description?: string) => string;95 error: (title: string, description?: string) => string;96 message: (title: string, description?: string) => string;97};98 99export const toast: ToastFn = Object.assign(100 (input: ToastInput) => pushToast(input),101 {102 success: (title: string, description?: string) =>103 pushToast({ title, description, tone: "success" }),104 error: (title: string, description?: string) =>105 pushToast({ title, description, tone: "error" }),106 message: (title: string, description?: string) =>107 pushToast({ title, description, tone: "default" }),108 },109);110 111const EASE = [0.25, 0.1, 0.25, 1] as const; // soft Sonner-like ease112const ENTER_MS = 0.3;113const EXIT_MS = 0.2;114const STACK_GAP = 14;115const TOAST_H = 64; // approximate for collapsed offset116const DISMISS_DY = 48;117const DISMISS_V = 0.11; // px/ms ≈ Sonner velocity118 119export type ToasterProps = {120 limit?: number;121 position?: "bottom-center" | "bottom-right";122 className?: string;123};124 125export function Toaster({126 limit = 3,127 position = "bottom-center",128 className,129}: ToasterProps) {130 const reduce = useReducedMotion();131 const [mounted, setMounted] = useState(false);132 const [expanded, setExpanded] = useState(false);133 const [paused, setPaused] = useState(false);134 const items = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);135 136 useEffect(() => {137 setMounted(true);138 }, []);139 140 useEffect(() => {141 limitCap = limit;142 if (toasts.length > limit) {143 toasts = toasts.slice(0, limit);144 emit();145 }146 }, [limit]);147 148 // Pause when tab hidden149 useEffect(() => {150 const onVis = () => {151 if (document.visibilityState === "hidden") setPaused(true);152 else setPaused(false);153 };154 document.addEventListener("visibilitychange", onVis);155 return () => document.removeEventListener("visibilitychange", onVis);156 }, []);157 158 if (!mounted) return null;159 160 return createPortal(161 <div162 className={cn(163 "pointer-events-none fixed inset-x-0 bottom-0 z-80 flex p-4 sm:p-6",164 position === "bottom-right"165 ? "justify-end"166 : "justify-center sm:justify-center",167 className,168 )}169 aria-live="polite"170 aria-relevant="additions removals"171 >172 <div173 className="pointer-events-auto relative w-full max-w-[360px]"174 style={{175 // Room for stacked + expanded toasts176 minHeight: Math.max(177 TOAST_H + 24,178 expanded179 ? items.length * (TOAST_H + STACK_GAP)180 : TOAST_H + Math.min(items.length - 1, 2) * 10,181 ),182 }}183 onMouseEnter={() => {184 setExpanded(true);185 setPaused(true);186 }}187 onMouseLeave={() => {188 setExpanded(false);189 if (document.visibilityState !== "hidden") setPaused(false);190 }}191 onFocusCapture={() => {192 setExpanded(true);193 setPaused(true);194 }}195 onBlurCapture={(e) => {196 if (!e.currentTarget.contains(e.relatedTarget as Node)) {197 setExpanded(false);198 if (document.visibilityState !== "hidden") setPaused(false);199 }200 }}201 >202 <AnimatePresence initial={false}>203 {items.map((t, index) => (204 <ToastCard205 key={t.id}206 toast={t}207 index={index}208 total={items.length}209 expanded={expanded}210 paused={paused}211 reduce={reduce}212 onDismiss={() => removeToast(t.id)}213 />214 ))}215 </AnimatePresence>216 </div>217 </div>,218 document.body,219 );220}221 222function ToastCard({223 toast: t,224 index,225 total,226 expanded,227 paused,228 reduce,229 onDismiss,230}: {231 toast: ToastRecord;232 index: number;233 total: number;234 expanded: boolean;235 paused: boolean;236 reduce: boolean | null;237 onDismiss: () => void;238}) {239 const y = useMotionValue(0);240 const [dragging, setDragging] = useState(false);241 const samples = useRef<{ t: number; y: number }[]>([]);242 const remainingRef = useRef(t.remaining);243 const lastTick = useRef<number | null>(null);244 245 // Keep remaining in sync when toast identity is same246 useEffect(() => {247 remainingRef.current = t.remaining;248 }, [t.id, t.remaining]);249 250 useEffect(() => {251 if (t.duration <= 0) return;252 let raf = 0;253 254 const loop = (now: number) => {255 if (paused || dragging) {256 lastTick.current = now;257 raf = requestAnimationFrame(loop);258 return;259 }260 if (lastTick.current == null) lastTick.current = now;261 const delta = now - lastTick.current;262 lastTick.current = now;263 remainingRef.current -= delta;264 if (remainingRef.current <= 0) {265 onDismiss();266 return;267 }268 raf = requestAnimationFrame(loop);269 };270 271 raf = requestAnimationFrame(loop);272 return () => cancelAnimationFrame(raf);273 }, [paused, dragging, onDismiss, t.duration, t.id]);274 275 const front = index === 0;276 const offsetY = expanded277 ? -index * (TOAST_H + STACK_GAP)278 : -index * 12;279 const scale = expanded ? 1 : 1 - Math.min(index, 2) * 0.05;280 281 const settle = useCallback(282 (vy: number) => {283 animate(y, 0, {284 type: "spring",285 bounce: 0,286 duration: 0.35,287 velocity: vy,288 });289 },290 [y],291 );292 293 const flingOut = useCallback(294 (vy: number) => {295 const target =296 typeof window !== "undefined" ? window.innerHeight : 640;297 animate(y, target, {298 type: "spring",299 bounce: 0,300 duration: 0.38,301 velocity: vy,302 onComplete: onDismiss,303 });304 },305 [y, onDismiss],306 );307 308 const toneBorder =309 t.tone === "success"310 ? "border-emerald-600/30"311 : t.tone === "error"312 ? "border-red-600/30"313 : "border-(--color-rule)";314 315 return (316 <motion.div317 layout318 initial={319 reduce320 ? { opacity: 0 }321 : { opacity: 0, y: 24, scale: 0.97 }322 }323 animate={{324 opacity: front || expanded ? 1 : Math.max(0.55, 1 - index * 0.2),325 y: offsetY,326 scale,327 zIndex: total - index,328 }}329 exit={330 reduce331 ? { opacity: 0, transition: { duration: 0.12 } }332 : {333 opacity: 0,334 y: 24,335 scale: 0.97,336 transition: { duration: EXIT_MS, ease: EASE },337 }338 }339 transition={340 reduce341 ? { duration: 0.15 }342 : {343 type: "spring" as const,344 bounce: 0,345 duration: ENTER_MS,346 }347 }348 className={cn(349 "absolute inset-x-0 bottom-0",350 "w-full",351 !front && !expanded && "pointer-events-none",352 )}353 >354 <motion.div355 style={{ y }}356 className={cn(357 "rounded-lg border bg-(--color-paper)/95",358 "px-4 py-3 shadow-[0_12px_40px_rgba(0,0,0,0.12)] backdrop-blur-xl",359 "dark:shadow-[0_12px_40px_rgba(0,0,0,0.45)]",360 toneBorder,361 "touch-none select-none",362 )}363 onPointerDown={(e) => {364 if (reduce || !front) return;365 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);366 setDragging(true);367 y.set(0);368 samples.current = [{ t: performance.now(), y: e.clientY }];369 }}370 onPointerMove={(e) => {371 if (!dragging || reduce) return;372 const first = samples.current[0];373 if (!first) return;374 const dy = Math.max(0, e.clientY - first.y);375 y.set(dy);376 samples.current.push({ t: performance.now(), y: e.clientY });377 if (samples.current.length > 6) samples.current.shift();378 }}379 onPointerUp={(e) => {380 if (!dragging || reduce) return;381 setDragging(false);382 try {383 (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);384 } catch {385 /* noop */386 }387 const arr = samples.current;388 const last = arr[arr.length - 1];389 const prev = arr[Math.max(0, arr.length - 3)];390 const dt = Math.max(16, (last?.t ?? 0) - (prev?.t ?? 0));391 const vy = ((last?.y ?? 0) - (prev?.y ?? 0)) / dt;392 const dy = y.get();393 if (dy > DISMISS_DY || vy > DISMISS_V) {394 flingOut(vy * 1000);395 } else {396 settle(vy * 1000);397 }398 }}399 >400 <div className="flex items-start gap-3">401 <div className="min-w-0 flex-1">402 <p className="text-sm font-medium text-(--color-ink)">403 {t.title}404 </p>405 {t.description ? (406 <p className="mt-0.5 text-xs leading-relaxed text-(--color-ink-muted)">407 {t.description}408 </p>409 ) : null}410 </div>411 <button412 type="button"413 aria-label="Dismiss"414 onClick={onDismiss}415 className={cn(416 "flex size-10 shrink-0 items-center justify-center rounded-sm",417 "text-(--color-ink-muted) outline-none",418 "transition-[transform,color] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",419 "hover:text-(--color-ink) active:scale-[0.96]",420 "focus-visible:ring-2 focus-visible:ring-(--color-focus)",421 )}422 >423 <X className="size-3.5 stroke-[1.5]" />424 </button>425 </div>426 </motion.div>427 </motion.div>428 );429}430 431/** @deprecated Use `<Toaster />` + `toast()` instead */432export function ToastStackProvider({433 children,434 limit = 3,435 className,436}: {437 children?: React.ReactNode;438 limit?: number;439 className?: string;440}) {441 return (442 <>443 {children}444 <Toaster limit={limit} className={className} />445 </>446 );447}448 449/** @deprecated Use `toast` from this module */450export function useToastStack() {451 return {452 toast: (input: ToastInput) => pushToast(input),453 dismiss,454 };455}
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.