UIOverlays and Menusmotion/react

Toast

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 props
4000
3

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add toast

Writes 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.

example.tsx
1import { Toast } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Toast />6  );7}

Props

PropTypeDefault
position

Where the stack is pinned in the window.

'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right''bottom-right'
duration

Auto-dismiss time in ms (0 keeps toasts until dismissed); paused while hovered or focused.

number4000
title

Title of the default toast.

string'Event has been created'
description

Description of the default toast.

string'Sunday, December 03, 2023 at 9:00 AM'
actionLabel

Action button on the default toast (empty hides it).

string'Undo'
promiseDelay

Milliseconds before the promise toast resolves to success.

number2000
maxVisible

Toasts visible in the stack at once.

number3
gap

Gap between toasts when expanded, in pixels.

number12
expand

Keep the stack expanded instead of expanding on hover.

booleanfalse
closeButton

Show a close button on each toast.

booleantrue
swipeToDismiss

Allow dismissing toasts by dragging them sideways.

booleantrue
richColors

Tint success and error toasts.

booleanfalse
accentColor

Spinner, focus ring, and accent color.

string'#6366f1'
className

Merged onto the root.

string''
style

Merged onto the root.

React.CSSPropertiesundefined

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

components/ui/toast.tsx
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}