Skip to content
← Components

Toast Stack

Source

toast-stack.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

type Toast = { id: number; title: string; body: string };

const GAP = 10;
const PEEK = 12;
const DURATION = 4500;

/**
 * Stacked notifications that fan out on hover or focus. Collapsed, older
 * toasts tuck behind the newest one and scale down; expanded, they lay out
 * with real heights. Timers pause while the stack is expanded so nothing
 * disappears while someone is reading it.
 */
export function ToastStack({
  toasts,
  onDismiss,
  visible = 3,
}: {
  toasts: Toast[];
  onDismiss: (id: number) => void;
  /** How many toasts show at once; older ones wait behind them. */
  visible?: number;
}) {
  const [expanded, setExpanded] = useState(false);
  const [heights, setHeights] = useState<Record<number, number>>({});

  const measure = useCallback((id: number, el: HTMLDivElement | null) => {
    if (!el) return;
    const h = el.offsetHeight;
    setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
  }, []);

  // Newest first.
  const ordered = [...toasts].reverse();
  const frontHeight = ordered[0] ? (heights[ordered[0].id] ?? 64) : 0;
  const expandedHeight = ordered
    .slice(0, visible)
    .reduce((sum, t, i) => sum + (heights[t.id] ?? 64) + (i ? GAP : 0), 0);
  const collapsedHeight = frontHeight + PEEK * Math.max(0, Math.min(ordered.length, visible) - 1);

  return (
    <section
      aria-label="Notifications"
      onPointerEnter={() => setExpanded(true)}
      onPointerLeave={() => setExpanded(false)}
      onFocus={() => setExpanded(true)}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node)) setExpanded(false);
      }}
      className="relative w-full max-w-[340px]"
    >
      <ol
        role="status"
        aria-live="polite"
        className="relative transition-[height] duration-(--duration-move) ease-out"
        style={{ height: expanded ? expandedHeight : collapsedHeight }}
      >
        {ordered.map((toast, index) => {
          const offset = ordered.slice(0, index).reduce((sum, t) => sum + (heights[t.id] ?? 64) + GAP, 0);
          const hidden = index >= visible;
          const y = expanded ? offset : index * PEEK;
          const scale = expanded ? 1 : 1 - index * 0.05;
          return (
            <ToastItem
              key={toast.id}
              toast={toast}
              paused={expanded}
              onDismiss={onDismiss}
              measureRef={(el) => measure(toast.id, el)}
              style={{
                transform: `translateY(${y}px) scale(${scale})`,
                opacity: hidden ? 0 : 1,
                zIndex: toasts.length - index,
                pointerEvents: hidden ? "none" : "auto",
                height: expanded || index === 0 ? heights[toast.id] : frontHeight,
              }}
            />
          );
        })}
      </ol>
    </section>
  );
}

function ToastItem({
  toast,
  paused,
  onDismiss,
  measureRef,
  style,
}: {
  toast: Toast;
  paused: boolean;
  onDismiss: (id: number) => void;
  measureRef: (el: HTMLDivElement | null) => void;
  style: React.CSSProperties;
}) {
  const [mounted, setMounted] = useState(false);
  const remaining = useRef(DURATION);

  useEffect(() => {
    const raf = requestAnimationFrame(() => setMounted(true));
    return () => cancelAnimationFrame(raf);
  }, []);

  useEffect(() => {
    if (paused) return;
    const start = Date.now();
    const id = setTimeout(() => onDismiss(toast.id), remaining.current);
    return () => {
      clearTimeout(id);
      remaining.current -= Date.now() - start;
    };
  }, [paused, onDismiss, toast.id]);

  return (
    <li
      style={{
        ...style,
        transform: mounted ? style.transform : "translateY(-24px) scale(0.96)",
        opacity: mounted ? style.opacity : 0,
      }}
      className="absolute inset-x-0 top-0 origin-top overflow-hidden rounded-xl bg-surface shadow-lg transition-[transform,opacity,height] duration-(--duration-move) ease-out"
    >
      {/* Measured at natural height; the <li> may be clipped shorter while collapsed. */}
      <div ref={measureRef} className="flex items-start gap-3 p-3.5">
        <span aria-hidden className="mt-[7px] size-1.5 shrink-0 rounded-full bg-accent shadow-[0_0_0_1px_rgb(0_0_0/0.06)]" />
        <div className="min-w-0 flex-1">
          <p className="text-body font-medium text-ink">{toast.title}</p>
          <p className="text-meta text-muted">{toast.body}</p>
        </div>
        <button
          type="button"
          onClick={() => onDismiss(toast.id)}
          aria-label={`Dismiss: ${toast.title}`}
          className="-m-1 rounded-sm p-1 text-muted transition-colors duration-(--duration-exit) hover:bg-panel hover:text-ink hover:duration-(--duration-enter)"
        >
          <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="none">
            <path d="m4 4 8 8m0-8-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
          </svg>
        </button>
      </div>
    </li>
  );
}

const samples = [
  { title: "Deployment ready", body: "main@4f2a1c is live on production." },
  { title: "Invite accepted", body: "Ada joined the Design workspace." },
  { title: "Export complete", body: "board-final.png · 2.4 MB" },
  { title: "Comment added", body: "“Love the easing on this one.”" },
];

export default function Demo() {
  const [toasts, setToasts] = useState<Toast[]>([]);
  const next = useRef(0);

  const add = useCallback(() => {
    const sample = samples[next.current % samples.length];
    setToasts((t) => [...t, { id: next.current++, ...sample }]);
  }, []);

  const dismiss = useCallback((id: number) => setToasts((t) => t.filter((x) => x.id !== id)), []);

  // Seed a couple so the stack is visible in thumbnails and captures.
  useEffect(() => {
    const a = setTimeout(add, 300);
    const b = setTimeout(add, 700);
    const c = setTimeout(add, 1100);
    return () => [a, b, c].forEach(clearTimeout);
  }, [add]);

  // Sized to the stage, not the viewport. Narrow stages are short too, so they
  // reserve less room and fan out two toasts instead of three.
  const stage = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(3);
  useEffect(() => {
    const el = stage.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) => setVisible(entry.contentRect.width < 448 ? 2 : 3));
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={stage} className="@container flex w-full justify-center">
      <div className="flex h-48 w-full max-w-[340px] flex-col items-center gap-2.5 @md:h-70 @md:gap-6">
        <button
          type="button"
          onClick={add}
          className="h-9 shrink-0 rounded-md bg-surface px-3.5 text-body font-medium text-ink shadow-sm transition-transform duration-(--duration-exit) ease-out active:scale-[0.97]"
        >
          Send notification
        </button>
        <ToastStack toasts={toasts} onDismiss={dismiss} visible={visible} />
      </div>
    </div>
  );
}