A stacked toaster: notifications pile up with depth, fan out on hover or focus, auto-dismiss with pause-on-hover, swipe away sideways, and include success, error, and promise (loading → done) variants.
Preview
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add toastWrites components/ui/toast.tsx and components/styles/toast.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Toast } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Toast />6 );7}Props
Accessibility
Toasts render in a portal inside a labelled <section> whose list is an aria-live="polite" region, so new messages are announced; error toasts additionally use role="alert". Each toast is focusable, Escape dismisses the focused toast, and every toast has a labelled close button. Hovering or focusing the stack expands it and pauses auto-dismiss timers. Stacked-away toasts are aria-hidden and removed from the tab order. Reduced motion removes the spring stacking and slide animations.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/toast.module.css";7 8type ToastKind = "default" | "success" | "error" | "loading";9type ToastPosition = "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right";10 11interface ToastData {12 id: number;13 kind: ToastKind;14 title: string;15 description?: string;16 action?: boolean;17}18 19export interface ToastProps {20 /** Corner or edge where toasts stack */21 position?: ToastPosition;22 /** Auto-dismiss duration in milliseconds (0 keeps toasts until dismissed) */23 duration?: number;24 /** Title of the default toast */25 title?: string;26 /** Description of the default toast */27 description?: string;28 /** Label of the action button on the default toast (empty hides it) */29 actionLabel?: string;30 /** Time in ms before the promise toast resolves */31 promiseDelay?: number;32 /** Maximum number of toasts visible in the stack */33 maxVisible?: number;34 /** Gap between toasts when expanded, in pixels */35 gap?: number;36 /** Keep the stack permanently expanded instead of expanding on hover */37 expand?: boolean;38 /** Show the close button on each toast */39 closeButton?: boolean;40 /** Allow dismissing toasts by dragging them sideways */41 swipeToDismiss?: boolean;42 /** Tint success and error toasts with their status colors */43 richColors?: boolean;44 /** Accent color for the loading spinner, action button and focus */45 accentColor?: string;46 /** Optional className merged onto the root */47 className?: string;48 /** Optional inline styles merged onto the root */49 style?: React.CSSProperties;50}51 52const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];53const PEEK = 14;54 55function readThemeVars(el: Element | null): Record<string, string> {56 const out: Record<string, string> = {};57 if (!el) return out;58 const cs = getComputedStyle(el);59 for (const t of THEME_TOKENS) {60 const v = cs.getPropertyValue(t).trim();61 if (v) out[t] = v;62 }63 return out;64}65 66const exitVariants = {67 exit: (dir: number) => (dir ? { x: dir * 420, opacity: 0, transition: { duration: 0.2 } } : { opacity: 0, scale: 0.92, transition: { duration: 0.2 } }),68};69 70function StatusIcon({ kind }: { kind: ToastKind }) {71 if (kind === "default") return null;72 if (kind === "loading") return <span aria-hidden="true" className={styles.spinner} />;73 return (74 <svg aria-hidden="true" className={styles[kind]} width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">75 <circle cx="12" cy="12" r="9.5" />76 <path d={kind === "success" ? "m8 12.5 2.8 2.8L16.5 9" : "M9 9l6 6M15 9l-6 6"} />77 </svg>78 );79}80 81interface ItemProps {82 data: ToastData;83 index: number;84 offset: number;85 frontHeight: number;86 height?: number;87 expanded: boolean;88 hidden: boolean;89 bottom: boolean;90 duration: number;91 closeButton: boolean;92 swipe: boolean;93 actionLabel: string;94 reduce: boolean;95 onHeight: (id: number, h: number) => void;96 onDismiss: (id: number, dir: number) => void;97}98 99function ToastItem({ data, index, offset, frontHeight, height, expanded, hidden, bottom, duration, closeButton, swipe, actionLabel, reduce, onHeight, onDismiss }: ItemProps) {100 const innerRef = React.useRef<HTMLDivElement>(null);101 const remaining = React.useRef(duration);102 103 React.useEffect(() => {104 const el = innerRef.current;105 if (!el) return;106 onHeight(data.id, el.offsetHeight);107 if (typeof ResizeObserver === "undefined") return;108 const ro = new ResizeObserver(() => onHeight(data.id, el.offsetHeight));109 ro.observe(el);110 return () => ro.disconnect();111 }, [data.id, onHeight]);112 113 React.useEffect(() => {114 if (expanded || data.kind === "loading" || duration <= 0) return;115 const start = performance.now();116 const t = setTimeout(() => onDismiss(data.id, 0), Math.max(0, remaining.current));117 return () => {118 clearTimeout(t);119 remaining.current -= performance.now() - start;120 };121 }, [expanded, data.kind, data.id, duration, onDismiss]);122 123 const sign = bottom ? -1 : 1;124 const front = index === 0;125 const y = sign * (expanded ? offset : index * PEEK);126 const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 380, damping: 34 };127 128 return (129 <motion.li130 tabIndex={hidden ? -1 : 0}131 role={data.kind === "error" ? "alert" : undefined}132 aria-hidden={hidden || undefined}133 data-kind={data.kind}134 className={styles.toast}135 style={{ zIndex: 100 - index, pointerEvents: hidden ? "none" : "auto", [bottom ? "bottom" : "top"]: 0 }}136 custom={0}137 variants={exitVariants}138 initial={{ opacity: 0, y: sign * -28, scale: 1 }}139 animate={{140 opacity: hidden ? 0 : 1,141 y,142 scale: expanded || reduce ? 1 : 1 - index * 0.05,143 height: expanded || front ? (height ?? "auto") : frontHeight || "auto",144 }}145 exit="exit"146 transition={{ ...spring, opacity: { duration: 0.2 } }}147 drag={swipe && !hidden ? "x" : false}148 dragSnapToOrigin149 dragElastic={0.6}150 onDragEnd={(_, info) => {151 if (Math.abs(info.offset.x) > 80 || Math.abs(info.velocity.x) > 600) onDismiss(data.id, Math.sign(info.offset.x || info.velocity.x));152 }}153 onKeyDown={(e) => {154 if (e.key === "Escape" && e.target === e.currentTarget) onDismiss(data.id, 0);155 }}156 >157 <motion.div ref={innerRef} className={styles.inner} animate={{ opacity: expanded || front ? 1 : 0 }} transition={{ duration: 0.15 }}>158 <StatusIcon kind={data.kind} />159 <div className={styles.text}>160 <div className={styles.title}>{data.title}</div>161 {data.description && <div className={styles.description}>{data.description}</div>}162 </div>163 {data.action && actionLabel && (164 <button type="button" className={styles.action} onClick={() => onDismiss(data.id, 0)}>165 {actionLabel}166 </button>167 )}168 {closeButton && (169 <button type="button" aria-label="Close notification" className={styles.close} onClick={() => onDismiss(data.id, 0)}>170 <svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">171 <path d="M18 6 6 18M6 6l12 12" />172 </svg>173 </button>174 )}175 </motion.div>176 </motion.li>177 );178}179 180export function Toast({181 position = "bottom-right",182 duration = 4000,183 title = "Event has been created",184 description = "Sunday, December 03, 2023 at 9:00 AM",185 actionLabel = "Undo",186 promiseDelay = 2000,187 maxVisible = 3,188 gap = 12,189 expand = false,190 closeButton = true,191 swipeToDismiss = true,192 richColors = false,193 accentColor = "#6366f1",194 className = "",195 style,196}: ToastProps) {197 const reduce = !!useReducedMotion();198 const rootRef = React.useRef<HTMLDivElement>(null);199 const nextId = React.useRef(1);200 const swipeDir = React.useRef(0);201 const timers = React.useRef(new Set<ReturnType<typeof setTimeout>>());202 const [mounted, setMounted] = React.useState(false);203 const [toasts, setToasts] = React.useState<ToastData[]>([]);204 const [heights, setHeights] = React.useState<Record<number, number>>({});205 const [hovered, setHovered] = React.useState(false);206 const [focused, setFocused] = React.useState(false);207 const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});208 209 React.useEffect(() => {210 setMounted(true);211 const set = timers.current;212 return () => set.forEach((t) => clearTimeout(t));213 }, []);214 215 const add = (t: Omit<ToastData, "id">) => {216 const id = nextId.current++;217 setThemeVars(readThemeVars(rootRef.current));218 setToasts((list) => [...list, { ...t, id }].slice(-10));219 return id;220 };221 222 const dismiss = React.useCallback((id: number, dir: number) => {223 swipeDir.current = dir;224 setToasts((list) => list.filter((t) => t.id !== id));225 }, []);226 227 const onHeight = React.useCallback((id: number, h: number) => setHeights((m) => (m[id] === h ? m : { ...m, [id]: h })), []);228 229 const fire = (kind: "default" | "success" | "error" | "promise") => {230 if (kind === "default") add({ kind, title, description, action: true });231 else if (kind === "success") add({ kind, title: "Changes saved", description: "Your profile has been updated." });232 else if (kind === "error") add({ kind, title: "Upload failed", description: "The file exceeds the 10 MB limit." });233 else {234 const id = add({ kind: "loading", title: "Deploying to production…", description: "Building and uploading assets" });235 new Promise<number>((resolve) => {236 const t = setTimeout(() => {237 timers.current.delete(t);238 resolve(promiseDelay);239 }, promiseDelay);240 timers.current.add(t);241 }).then((ms) => setToasts((list) => list.map((x) => (x.id === id ? { ...x, kind: "success", title: "Deployment complete", description: `Live in ${(ms / 1000).toFixed(1)}s` } : x))));242 }243 };244 245 const bottom = position.startsWith("bottom");246 const ordered = [...toasts].reverse();247 const visible = ordered.slice(0, Math.max(1, maxVisible));248 const expanded = expand || hovered || focused;249 const frontHeight = heights[ordered[0]?.id] ?? 0;250 const offsets: number[] = [];251 let acc = 0;252 for (const t of ordered) {253 offsets.push(acc);254 acc += (heights[t.id] ?? 0) + gap;255 }256 const listHeight = visible.length === 0 ? 0 : expanded ? offsets[visible.length - 1] + (heights[visible[visible.length - 1].id] ?? 0) : frontHeight + PEEK * (visible.length - 1);257 258 return (259 <div ref={rootRef} className={`${styles.root} ${className}`.trim()} style={{ "--mf-toast-accent": accentColor, ...style } as React.CSSProperties}>260 {(["default", "success", "error", "promise"] as const).map((k) => (261 <button key={k} type="button" className={`${styles.trigger} ${k === "default" ? styles.primary : ""}`.trim()} onClick={() => fire(k)}>262 {k === "default" ? "Show toast" : k[0].toUpperCase() + k.slice(1)}263 </button>264 ))}265 {mounted &&266 createPortal(267 <section aria-label="Notifications" className={styles.viewport} data-position={position} data-rich={richColors ? "" : undefined} style={{ ...themeVars, "--mf-toast-accent": accentColor } as React.CSSProperties}>268 <ol269 aria-live="polite"270 aria-relevant="additions text"271 className={styles.list}272 style={{ height: listHeight }}273 onPointerEnter={() => setHovered(true)}274 onPointerLeave={() => setHovered(false)}275 onFocus={() => setFocused(true)}276 onBlur={(e) => {277 if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);278 }}279 >280 <AnimatePresence custom={swipeDir.current}>281 {ordered.map((t, i) => (282 <ToastItem283 key={t.id}284 data={t}285 index={i}286 offset={offsets[i]}287 frontHeight={frontHeight}288 height={heights[t.id]}289 expanded={expanded}290 hidden={i >= visible.length}291 bottom={bottom}292 duration={duration}293 closeButton={closeButton}294 swipe={swipeToDismiss}295 actionLabel={actionLabel}296 reduce={reduce}297 onHeight={onHeight}298 onDismiss={dismiss}299 />300 ))}301 </AnimatePresence>302 </ol>303 </section>,304 document.body,305 )}306 </div>307 );308}