← All components
Week 4 · 2026-10-08

Stacking Toast Notifications

Toasts that stack like cards, fan out on hover, pause their timers, swipe away, and turn loading into success, with Undo and Retry actions.

Inbox · Acme Mail

Trigger a notification

Hover the stack to expand · swipe a toast sideways to dismiss

    Source

    Demo.module.css
    .frame {
      --surface: #fcfcfb;
      --ink: #0b0b0b;
      --ink-2: #52514e;
      --muted: #898781;
      --border: rgba(11, 11, 11, 0.1);
      --field: #f2f1ee;
    
      position: relative;
      width: 100%;
      max-width: 640px;
      min-height: 440px;
      overflow: hidden;
      border: 1px solid var(--border);
      border-radius: 16px;
      background: var(--surface);
      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 16px 40px -16px rgba(0, 0, 0, 0.25);
      color: var(--ink);
      font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    }
    
    @media (prefers-color-scheme: dark) {
      .frame {
        --surface: #17171a;
        --ink: #ffffff;
        --ink-2: #c3c2b7;
        --border: rgba(255, 255, 255, 0.1);
        --field: #222226;
        box-shadow: 0 16px 40px -16px rgba(0, 0, 0, 0.7);
      }
    }
    
    .chrome {
      display: flex;
      align-items: center;
      gap: 7px;
      height: 40px;
      padding: 0 14px;
      border-bottom: 1px solid var(--border);
      background: var(--field);
    }
    .chrome span { width: 11px; height: 11px; border-radius: 50%; background: #ff5f57; }
    .chrome span:nth-child(2) { background: #febc2e; }
    .chrome span:nth-child(3) { background: #28c840; }
    .chrome strong { margin-left: 10px; color: var(--ink-2); font-size: 12.5px; font-weight: 600; }
    
    /* Bottom padding leaves room for the toast stack so it doesn't cover the buttons. */
    .body { padding: 22px 22px 130px; }
    .heading { margin: 0 0 12px; color: var(--ink-2); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
    
    .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 40%), 1fr)); gap: 8px; }
    .trigger {
      display: flex;
      align-items: center;
      gap: 9px;
      padding: 11px 12px;
      border: 1px solid var(--border);
      border-radius: 10px;
      background: var(--surface);
      color: var(--ink);
      font: inherit;
      font-size: 13.5px;
      font-weight: 500;
      text-align: left;
      cursor: pointer;
      transition: background 0.15s ease, transform 0.1s ease;
    }
    .trigger:hover { background: var(--field); }
    .trigger:active { transform: scale(0.98); }
    .trigger:focus-visible { outline: 2px solid #2e90fa; outline-offset: 2px; }
    
    .dot { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #2e90fa; }
    .trigger[data-tone="success"] .dot { background: #12b76a; }
    .trigger[data-tone="warning"] .dot { background: #f79009; }
    .trigger[data-tone="error"] .dot { background: #f04438; }
    .trigger[data-tone="loading"] .dot { background: var(--muted); }
    .trigger[data-tone="burst"] .dot { background: conic-gradient(#12b76a 0 25%, #2e90fa 0 50%, #f79009 0 75%, #f04438 0); }
    
    .hint { margin: 14px 0 0; color: var(--muted); font-size: 12px; }
    
    Demo.tsx
    "use client";
    
    import Toaster from "./Toaster";
    import { useToaster } from "./useToaster";
    import styles from "./Demo.module.css";
    
    const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
    
    // A mock mail app whose buttons fire each kind of toast.
    export default function Demo() {
      const { toasts, show, dismiss, promise } = useToaster();
    
      const actions = [
        {
          label: "Save changes",
          tone: "success",
          run: () => show({ type: "success", title: "Changes saved", description: "Your signature was updated." }),
        },
        {
          label: "Upload report.pdf",
          tone: "loading",
          run: () =>
            promise(wait(2200), { loading: "Uploading report.pdf…", success: "report.pdf uploaded", error: "Upload failed" }).catch(() => {}),
        },
        {
          label: "Delete email",
          tone: "warning",
          run: () =>
            show({
              type: "warning",
              title: "Email moved to Trash",
              description: "“Q3 budget review” from Imran",
              duration: 6000,
              action: { label: "Undo", onClick: () => show({ type: "success", title: "Email restored" }) },
            }),
        },
        {
          label: "Go offline",
          tone: "error",
          run: () =>
            show({
              type: "error",
              title: "Connection lost",
              description: "Changes will sync when you're back online.",
              action: {
                label: "Retry",
                onClick: () =>
                  promise(wait(1500), { loading: "Reconnecting…", success: "Back online", error: "Still offline" }).catch(() => {}),
              },
            }),
        },
        {
          label: "New message",
          tone: "info",
          run: () => show({ type: "info", title: "New message from Ayesha", description: "“Can we move the 3pm meeting to 4?”" }),
        },
        {
          label: "Burst ×4",
          tone: "burst",
          run: () => {
            const items = [
              { type: "info", title: "Nusrat joined #design" },
              { type: "success", title: "Invoice #1042 paid" },
              { type: "info", title: "3 new comments on “Roadmap”" },
              { type: "warning", title: "Storage 90% full" },
            ] as const;
            items.forEach((t, i) => setTimeout(() => show({ ...t }), i * 180));
          },
        },
      ];
    
      return (
        <div className={styles.frame}>
          <div className={styles.chrome}>
            <span />
            <span />
            <span />
            <strong>Inbox · Acme Mail</strong>
          </div>
          <div className={styles.body}>
            <p className={styles.heading}>Trigger a notification</p>
            <div className={styles.grid}>
              {actions.map((a) => (
                <button key={a.label} type="button" className={styles.trigger} data-tone={a.tone} onClick={a.run}>
                  <span className={styles.dot} />
                  {a.label}
                </button>
              ))}
            </div>
            <p className={styles.hint}>Hover the stack to expand · swipe a toast sideways to dismiss</p>
          </div>
          <Toaster toasts={toasts} onDismiss={dismiss} />
        </div>
      );
    }
    
    Toaster.module.css
    .region {
      --surface: #ffffff;
      --ink: #0b0b0b;
      --ink-2: #52514e;
      --border: rgba(11, 11, 11, 0.09);
      --success: #12b76a;
      --error: #f04438;
      --warning: #f79009;
      --info: #2e90fa;
      --ease: cubic-bezier(0.21, 1.02, 0.73, 1);
    
      position: absolute;
      right: 16px;
      bottom: 16px;
      z-index: 10;
      width: min(356px, calc(100% - 32px));
      font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    }
    
    @media (prefers-color-scheme: dark) {
      .region {
        --surface: #24242a;
        --ink: #ffffff;
        --ink-2: #c3c2b7;
        --border: rgba(255, 255, 255, 0.1);
      }
    }
    
    .stack { position: relative; margin: 0; padding: 0; list-style: none; transition: height 0.4s var(--ease); }
    
    .toast {
      position: absolute;
      right: 0;
      bottom: 0;
      left: 0;
      overflow: hidden;
      border: 1px solid var(--border);
      border-radius: 14px;
      background: var(--surface);
      box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 12px 28px -10px rgba(0, 0, 0, 0.3);
      color: var(--ink);
      touch-action: pan-y;
      user-select: none;
      transform-origin: 50% 0;
      transition:
        transform 0.4s var(--ease),
        opacity 0.35s ease,
        height 0.4s var(--ease);
      cursor: grab;
    }
    .toast[data-dragging="true"] { transition: opacity 0.1s ease; cursor: grabbing; }
    
    .inner {
      display: grid;
      grid-template-columns: auto 1fr auto auto;
      align-items: start;
      gap: 10px;
      padding: 13px 12px 13px 14px;
    }
    
    .icon {
      display: grid;
      place-items: center;
      width: 22px;
      height: 22px;
      margin-top: 1px;
      border-radius: 50%;
      color: #fff;
    }
    .icon[data-type="success"] { background: var(--success); }
    .icon[data-type="error"] { background: var(--error); }
    .icon[data-type="warning"] { background: var(--warning); }
    .icon[data-type="info"] { background: var(--info); }
    .icon svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round; }
    
    .spinner {
      width: 18px;
      height: 18px;
      border: 2.4px solid color-mix(in srgb, var(--ink) 15%, transparent);
      border-top-color: var(--ink-2);
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
    }
    
    .text { min-width: 0; }
    .title { margin: 1px 0 0; font-size: 14px; font-weight: 600; line-height: 1.35; }
    .description { margin: 2px 0 0; color: var(--ink-2); font-size: 13px; line-height: 1.4; }
    
    .action {
      align-self: center;
      padding: 5px 11px;
      border: 0;
      border-radius: 8px;
      background: var(--ink);
      color: var(--surface);
      font: inherit;
      font-size: 12.5px;
      font-weight: 600;
      cursor: pointer;
    }
    .action:hover { opacity: 0.85; }
    
    .close {
      display: grid;
      place-items: center;
      width: 24px;
      height: 24px;
      padding: 0;
      border: 0;
      border-radius: 6px;
      background: none;
      color: var(--ink-2);
      cursor: pointer;
      opacity: 0;
      transition: opacity 0.15s ease, background 0.15s ease;
    }
    .toast:hover .close,
    .close:focus-visible { opacity: 1; }
    .close:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); }
    .close svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; }
    .action:focus-visible,
    .close:focus-visible { outline: 2px solid var(--info); outline-offset: 1px; }
    @media (hover: none) {
      .close { opacity: 1; }
    }
    
    /* Time left before auto-dismiss */
    .progress {
      position: absolute;
      bottom: 0;
      left: 0;
      width: 100%;
      height: 2.5px;
      transform-origin: left;
      animation: shrink linear forwards;
    }
    .toast[data-type="success"] .progress { background: var(--success); }
    .toast[data-type="error"] .progress { background: var(--error); }
    .toast[data-type="warning"] .progress { background: var(--warning); }
    .toast[data-type="info"] .progress { background: var(--info); }
    
    @keyframes shrink { from { transform: scaleX(1); } to { transform: scaleX(0); } }
    @keyframes spin { to { transform: rotate(360deg); } }
    
    @media (prefers-reduced-motion: reduce) {
      .toast { transition: opacity 0.2s ease; }
      .stack { transition: none; }
    }
    
    Toaster.tsx
    "use client";
    
    import { useEffect, useLayoutEffect, useRef, useState, type PointerEvent } from "react";
    import { type ToastItem, type ToastType } from "./useToaster";
    import styles from "./Toaster.module.css";
    
    type Props = {
      toasts: ToastItem[];
      onDismiss: (id: number, exitX?: number) => void;
    };
    
    const GAP = 10;
    const PEEK = 14; // how much each older toast peeks out above the one in front
    const VISIBLE_WHEN_STACKED = 3;
    const SWIPE_DISMISS = 90;
    
    const ICONS: Record<ToastType, React.ReactNode> = {
      success: <path d="M7 12.5l3 3 7-7" />,
      error: <path d="M9 9l6 6m0-6-6 6" />,
      warning: <path d="M12 8v5m0 3.2v.1" />,
      info: <path d="M12 11v5m0-8.2v.1" />,
      loading: null,
    };
    
    export default function Toaster({ toasts, onDismiss }: Props) {
      const [hovered, setHovered] = useState(false);
      const [focused, setFocused] = useState(false);
      const [heights, setHeights] = useState<Record<number, number>>({});
    
      // Newest toast is at the front (index 0).
      const ordered = [...toasts].reverse();
      const live = ordered.filter((t) => !t.removing);
      const expanded = (hovered || focused) && live.length > 0;
      const paused = hovered || focused;
      const frontHeight = heights[live[0]?.id] ?? 64;
    
      // Collapse once the last toast is gone, so the next one starts stacked.
      useEffect(() => {
        if (toasts.length === 0) setFocused(false);
      }, [toasts.length]);
    
      let offset = 0;
      const offsets = new Map<number, number>();
      live.forEach((t, i) => {
        offsets.set(t.id, offset);
        offset += (heights[t.id] ?? 64) + GAP;
        if (i === live.length - 1) offset -= GAP;
      });
    
      const stackHeight = live.length === 0 ? 0 : expanded ? offset : frontHeight + PEEK * Math.min(live.length - 1, VISIBLE_WHEN_STACKED - 1);
    
      return (
        <section
          className={styles.region}
          aria-label="Notifications"
          onMouseEnter={() => setHovered(true)}
          onMouseLeave={() => setHovered(false)}
          onFocus={() => setFocused(true)}
          onBlur={(e) => {
            if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
          }}
        >
          <ol className={styles.stack} style={{ height: stackHeight }} data-expanded={expanded}>
            {ordered.map((toast) => {
              const index = live.indexOf(toast);
              return (
                <Toast
                  key={toast.id}
                  toast={toast}
                  index={index < 0 ? 0 : index}
                  expanded={expanded}
                  offset={offsets.get(toast.id) ?? 0}
                  frontHeight={frontHeight}
                  height={heights[toast.id]}
                  paused={paused}
                  onHeight={(h) => setHeights((prev) => (prev[toast.id] === h ? prev : { ...prev, [toast.id]: h }))}
                  onDismiss={onDismiss}
                />
              );
            })}
          </ol>
        </section>
      );
    }
    
    type ToastProps = {
      toast: ToastItem;
      index: number;
      expanded: boolean;
      offset: number;
      frontHeight: number;
      height: number | undefined;
      paused: boolean;
      onHeight: (height: number) => void;
      onDismiss: (id: number, exitX?: number) => void;
    };
    
    function Toast({ toast, index, expanded, offset, frontHeight, height, paused, onHeight, onDismiss }: ToastProps) {
      const inner = useRef<HTMLDivElement>(null);
      const [mounted, setMounted] = useState(false);
      const [drag, setDrag] = useState<{ startX: number; dx: number } | null>(null);
      const remaining = useRef(toast.duration);
      const lastPosition = useRef({ y: 0, scale: 1 });
    
      useLayoutEffect(() => {
        // +2 for the top and bottom border of the list item.
        if (inner.current) onHeight(inner.current.offsetHeight + 2);
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [toast.version]);
    
      useEffect(() => {
        const raf = requestAnimationFrame(() => setMounted(true));
        return () => cancelAnimationFrame(raf);
      }, []);
    
      // Restart the countdown whenever the toast is updated (e.g. loading → success).
      useEffect(() => {
        remaining.current = toast.duration;
      }, [toast.version, toast.duration]);
    
      // Auto-dismiss, pausing while the stack is hovered or focused.
      useEffect(() => {
        if (paused || toast.removing || drag || !Number.isFinite(toast.duration)) return;
        const started = Date.now();
        const timer = setTimeout(() => onDismiss(toast.id), remaining.current);
        return () => {
          clearTimeout(timer);
          remaining.current -= Date.now() - started;
        };
      }, [paused, toast.removing, toast.version, toast.duration, toast.id, drag, onDismiss]);
    
      function onPointerDown(e: PointerEvent<HTMLLIElement>) {
        if ((e.target as HTMLElement).closest("button")) return;
        e.currentTarget.setPointerCapture(e.pointerId);
        setDrag({ startX: e.clientX, dx: 0 });
      }
      function onPointerMove(e: PointerEvent<HTMLLIElement>) {
        if (drag) setDrag({ ...drag, dx: e.clientX - drag.startX });
      }
      function onPointerUp() {
        if (!drag) return;
        if (Math.abs(drag.dx) > SWIPE_DISMISS) onDismiss(toast.id, Math.sign(drag.dx) * 420);
        setDrag(null);
      }
    
      const stacked = !expanded;
      const hidden = stacked && index >= VISIBLE_WHEN_STACKED;
      let x = drag?.dx ?? 0;
      let y = stacked ? -index * PEEK : -offset;
      let scale = stacked ? 1 - index * 0.06 : 1;
      let opacity = hidden ? 0 : 1 - Math.min(Math.abs(x) / 260, 0.7);
    
      if (!mounted) {
        y = 80;
        opacity = 0;
      }
      if (toast.removing) {
        // Leave from where it was, not from the slot it would get after removal.
        ({ y, scale } = lastPosition.current);
        if (toast.exitX) x = toast.exitX;
        else y += 24;
        opacity = 0;
        scale *= 0.96;
      } else if (mounted) {
        lastPosition.current = { y, scale };
      }
    
      const timed = Number.isFinite(toast.duration);
    
      return (
        <li
          className={styles.toast}
          data-type={toast.type}
          data-dragging={Boolean(drag)}
          role={toast.type === "error" ? "alert" : "status"}
          aria-live={toast.type === "error" ? "assertive" : "polite"}
          style={{
            transform: `translate(${x}px, ${y}px) scale(${scale})`,
            opacity,
            zIndex: 50 - index,
            // When stacked, older toasts take the front toast's height so their peeks line up.
            height: stacked && index > 0 ? frontHeight : height,
            pointerEvents: hidden || toast.removing ? "none" : undefined,
          }}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
        >
          <div ref={inner} className={styles.inner} aria-hidden={hidden || undefined}>
            <span className={styles.icon} data-type={toast.type} aria-hidden="true">
              {toast.type === "loading" ? (
                <span className={styles.spinner} />
              ) : (
                <svg viewBox="0 0 24 24">{ICONS[toast.type]}</svg>
              )}
            </span>
            <div className={styles.text}>
              <p className={styles.title}>{toast.title}</p>
              {toast.description && <p className={styles.description}>{toast.description}</p>}
            </div>
            {toast.action && (
              <button
                type="button"
                className={styles.action}
                onClick={() => {
                  toast.action!.onClick();
                  onDismiss(toast.id);
                }}
              >
                {toast.action.label}
              </button>
            )}
            <button type="button" className={styles.close} aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)}>
              <svg viewBox="0 0 24 24" aria-hidden="true">
                <path d="M7 7l10 10M17 7 7 17" />
              </svg>
            </button>
          </div>
          {timed && (
            <span
              key={toast.version}
              className={styles.progress}
              style={{ animationDuration: `${toast.duration}ms`, animationPlayState: paused || drag ? "paused" : "running" }}
            />
          )}
        </li>
      );
    }
    
    useToaster.ts
    "use client";
    
    import { useCallback, useState } from "react";
    
    export type ToastType = "success" | "error" | "warning" | "info" | "loading";
    
    export type ToastInput = {
      type?: ToastType;
      title: string;
      description?: string;
      action?: { label: string; onClick: () => void };
      /** Milliseconds before auto-dismiss. Loading toasts stay until updated. */
      duration?: number;
    };
    
    export type ToastItem = ToastInput & {
      id: number;
      type: ToastType;
      duration: number;
      /** Bumped on every update so timers and progress bars restart. */
      version: number;
      removing?: boolean;
      /** Horizontal fly-out distance when dismissed by a swipe. */
      exitX?: number;
    };
    
    const MAX_TOASTS = 5;
    const DEFAULT_DURATION = 4000;
    export const EXIT_MS = 350;
    
    let nextId = 1;
    
    const durationFor = (input: ToastInput) =>
      input.duration ?? (input.type === "loading" ? Infinity : DEFAULT_DURATION);
    
    export function useToaster() {
      const [toasts, setToasts] = useState<ToastItem[]>([]);
    
      const dismiss = useCallback((id: number, exitX = 0) => {
        setToasts((ts) => ts.map((t) => (t.id === id ? { ...t, removing: true, exitX } : t)));
        setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), EXIT_MS);
      }, []);
    
      const show = useCallback((input: ToastInput) => {
        const id = nextId++;
        const item: ToastItem = { ...input, id, type: input.type ?? "info", duration: durationFor(input), version: 0 };
        setToasts((ts) => [...ts, item].slice(-MAX_TOASTS));
        return id;
      }, []);
    
      const update = useCallback((id: number, input: ToastInput) => {
        setToasts((ts) =>
          ts.map((t) =>
            t.id === id ? { ...t, ...input, type: input.type ?? t.type, duration: durationFor(input), version: t.version + 1 } : t,
          ),
        );
      }, []);
    
      /** Shows a loading toast that turns into success or error when the promise settles. */
      const promise = useCallback(
        async <T,>(work: Promise<T>, messages: { loading: string; success: string; error: string }) => {
          const id = show({ type: "loading", title: messages.loading });
          try {
            const value = await work;
            update(id, { type: "success", title: messages.success });
            return value;
          } catch (err) {
            update(id, { type: "error", title: messages.error });
            throw err;
          }
        },
        [show, update],
      );
    
      return { toasts, show, update, dismiss, promise };
    }