Teseria
All elements

Toast

Toaster + toast(): call toast.success("Saved") from anywhere, render <Toaster /> once. The front toast is full size, the ones behind fold into slivers, hover springs them apart (and pauses the timers), a swipe right dismisses.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

Toaster.tsx227 lines
"use client";
// Toaster + toast(): call toast.success("Saved") from anywhere, render <Toaster /> once. The front toast is full
// size, the ones behind fold into slivers, hover springs them apart (and pauses the timers), a swipe right dismisses.
// Only the first mounted <Toaster /> draws, so a toast never shows twice.
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";

export type ToastKind = "info" | "success" | "error" | "warning";
export type ToasterCue = "arrive" | "done" | "caution" | "push" | "swipe";

export type ToastInput = {
  title: ReactNode;
  body?: ReactNode;
  kind?: ToastKind;
  action?: { label: string; onClick: () => void };
  /** ms before it dismisses itself; 0 keeps it until dismissed. */
  duration?: number;
};
type T = ToastInput & { id: number };

const subs = new Set<(t: T[]) => void>();
let list: T[] = [];
let seq = 0;
const emit = () => subs.forEach((f) => f([...list]));

/** Imperative API: toast({ title, kind }) · toast.success("Saved") · toast.dismiss(id) */
export const toast = Object.assign(
  (t: ToastInput) => {
    const item = { id: ++seq, kind: "info" as ToastKind, duration: 4500, ...t };
    list = [item, ...list];
    emit();
    return item.id;
  },
  {
    success: (title: ReactNode, body?: ReactNode) => toast({ title, body, kind: "success" }),
    error: (title: ReactNode, body?: ReactNode) => toast({ title, body, kind: "error" }),
    info: (title: ReactNode, body?: ReactNode) => toast({ title, body, kind: "info" }),
    warning: (title: ReactNode, body?: ReactNode) => toast({ title, body, kind: "warning" }),
    dismiss: (id?: number) => {
      list = id === undefined ? [] : list.filter((t) => t.id !== id);
      emit();
    },
  },
);

// Mounted toasters in mount order; the first is the one that draws.
const hosts: object[] = [];
const hostSubs = new Set<() => void>();
const hostChanged = () => hostSubs.forEach((f) => f());

export type ToasterProps = {
  /** Corner (or edge centre) the stack sits in. */
  position?: "bottom-right" | "bottom-center" | "top-right" | "top-center";
  /** Sound hook for each new toast: "done" success, "caution" error or warning, "arrive" otherwise; "push" when dismissed by its X, "swipe" when swiped away. */
  onSound?: (cue: ToasterCue) => void;
  /** Accessible name of each toast's dismiss button. */
  dismissLabel?: string;
  /** Static toasts drawn in place as an open list, apart from toast(); for previews. */
  preview?: ToastInput[];
  className?: string;
  style?: CSSProperties;
};

const ICON = { success: CheckCircle2, info: Info, error: XCircle, warning: AlertTriangle };
const CARD_HEIGHT = 64;
const CARD_GAP = 10;
const PEEK = 12;
const MAX_SHOWN = 3;
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;

export function Toaster({ position = "bottom-right", onSound, dismissLabel = "Dismiss", preview, className, style }: ToasterProps) {
  const [lead, setLead] = useState(false);
  const [items, setItems] = useState<T[]>([]);
  const [kept, setKept] = useState(() => preview?.map((t, i): T => ({ kind: "info", ...t, id: -1 - i })));
  const [hover, setOpen] = useState(false);
  const open = hover || !!kept;
  const drop = (id: number) => (kept ? setKept(kept.filter((t) => t.id !== id)) : toast.dismiss(id));
  const timers = useRef(new Map<number, number>());
  const seen = useRef(new Set<number>(list.map((t) => t.id)));
  const root = useRef<HTMLDivElement>(null);
  const snd = useRef(onSound);
  snd.current = onSound;
  const top = position.startsWith("top");
  const still = useReducedMotion();

  useEffect(() => {
    if (preview) return; // a preview never takes the queue
    const me = {};
    const sync = () => setLead(hosts[0] === me);
    hosts.push(me);
    hostSubs.add(sync);
    hostChanged();
    return () => {
      hosts.splice(hosts.indexOf(me), 1);
      hostSubs.delete(sync);
      hostChanged();
    };
  }, [preview]);
  useEffect(() => {
    if (!lead) return;
    setItems([...list]);
    subs.add(setItems);
    return () => void subs.delete(setItems);
  }, [lead]);
  // the button that was pressed leaves with its toast: hand focus to a neighbour's dismiss button, or fold the stack when none is left
  const handOff = (btn: HTMLElement) => {
    const xs = [...(root.current?.querySelectorAll<HTMLElement>(".tos-x") ?? [])];
    const at = xs.indexOf(xs.find((x) => x.parentElement === btn.parentElement) ?? btn);
    const next = xs[at + 1] ?? xs[at - 1];
    if (next) next.focus();
    else setOpen(false);
  };
  const runAction = (t: T, btn: HTMLElement) => {
    handOff(btn);
    t.action?.onClick();
    drop(t.id);
  };
  const dismissByHand = (id: number, btn: HTMLElement) => {
    handOff(btn);
    snd.current?.("push");
    drop(id);
  };
  // one cue per new toast
  useEffect(() => {
    if (!lead) return;
    for (const t of items) {
      if (seen.current.has(t.id)) continue;
      seen.current.add(t.id);
      snd.current?.(t.kind === "error" || t.kind === "warning" ? "caution" : t.kind === "success" ? "done" : "arrive");
    }
  }, [items, lead]);
  // auto-dismiss, paused while expanded
  useEffect(() => {
    if (!lead) return;
    const tm = timers.current;
    for (const t of items) {
      if (open || tm.has(t.id) || !t.duration) continue;
      const timer = window.setTimeout(() => {
        tm.delete(t.id);
        toast.dismiss(t.id);
      }, t.duration);
      tm.set(t.id, timer);
    }
    if (open) {
      tm.forEach((h) => clearTimeout(h));
      tm.clear();
    }
  }, [items, open, lead]);
  useEffect(() => {
    const tm = timers.current;
    return () => {
      tm.forEach((h) => clearTimeout(h));
      tm.clear();
    };
  }, []);

  // a manual popover lives in the top layer: it needs no portal and no z-index, and sits above any ancestor transform
  useEffect(() => {
    try {
      root.current?.showPopover?.();
    } catch {
      // already open, or a preview
    }
  });

  if (!lead && !kept) return null;
  const shown = (kept ?? items).slice(0, MAX_SHOWN);
  const n = shown.length;
  return (
    <div
      ref={root}
      popover={kept ? undefined : "manual"}
      className={className ? `tos-root ${className}` : "tos-root"}
      data-pos={kept ? undefined : position}
      data-open={open || undefined}
      style={{ height: open ? n * (CARD_HEIGHT + CARD_GAP) : CARD_HEIGHT + (n - 1) * PEEK, ...style }}
      onPointerEnter={() => setOpen(true)}
      onPointerLeave={() => setOpen(false)}
      onFocus={() => setOpen(true)}
      onBlur={(e) => !root.current?.contains(e.relatedTarget as Node) && setOpen(false)}
    >
      <AnimatePresence initial={false}>
        {shown.map((t, i) => {
          const Icon = ICON[t.kind ?? "info"];
          const sign = top ? 1 : -1;
          return (
            <motion.div
              key={t.id}
              layout
              className="tos-toast"
              data-kind={t.kind}
              style={{ zIndex: MAX_SHOWN - i, height: CARD_HEIGHT, [top ? "top" : "bottom"]: 0 }}
              initial={{ opacity: 0, y: -sign * 24, scale: 0.96, filter: "blur(6px)" }}
              animate={{ opacity: open ? 1 : 1 - i * 0.18, y: open ? sign * i * (CARD_HEIGHT + CARD_GAP) : sign * i * PEEK, scale: open ? 1 : 1 - i * 0.05, filter: "blur(0px)", transitionEnd: { filter: "none" } }}
              exit={{ opacity: 0, x: 60, transition: { duration: still ? 0 : 0.18, ease: [0.4, 0, 1, 1] } }}
              transition={still ? { duration: 0 } : { ...SNAP, opacity: { duration: 0.16 }, filter: { duration: 0.2 } }}
              drag="x"
              dragConstraints={{ left: 0, right: 0 }}
              dragElastic={{ left: 0.08, right: 0.6 }}
              dragSnapToOrigin
              onDragEnd={(_, info) => (info.offset.x > 90 || info.velocity.x > 500) && (snd.current?.("swipe"), drop(t.id))}
              aria-hidden={!open && i !== 0 ? true : undefined}
              role={t.kind === "error" ? "alert" : "status"}
            >
              <span className="tos-icon">
                <Icon />
              </span>
              <span className="tos-text">
                <b>{t.title}</b>
                {t.body && <span>{t.body}</span>}
              </span>
              {t.action && (
                <button type="button" className="tos-act" tabIndex={open || i === 0 ? 0 : -1} onClick={(e) => runAction(t, e.currentTarget)}>
                  {t.action.label}
                </button>
              )}
              <button type="button" className="tos-x" aria-label={dismissLabel} tabIndex={open || i === 0 ? 0 : -1} onClick={(e) => dismissByHand(t.id, e.currentTarget)}>
                <X />
              </button>
            </motion.div>
          );
        })}
      </AnimatePresence>
    </div>
  );
}
Toaster.css159 lines
/* Toaster. Prefix tos-. A manual popover (top layer) holding a stack of absolutely placed cards. */
.tos-root,
.tos-root * {
  box-sizing: border-box;
}
:where(.tos-root) button { margin: 0; padding: 0; border: 0; background: none; font: inherit; color: inherit; }
.tos-root {
  --tos-out: cubic-bezier(.22,1,.36,1);
  --tos-card: var(--ts-raised);
  position: fixed;
  inset: auto;
  z-index: 50;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  overflow: visible;
  width: min(360px, calc(100vw - 32px));
  font-family: var(--ts-font, system-ui, sans-serif);
  pointer-events: none;
  transition: height 420ms var(--tos-out);
}
[data-theme="dark"] .tos-root {
  --tos-card: var(--ts-tint-3);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .tos-root {
    --tos-card: var(--ts-tint-3);
  }
}
.tos-root[data-pos$="right"] {
  right: 16px;
}
.tos-root[data-pos$="center"] {
  left: 50%;
  translate: -50% 0;
}
.tos-root[data-pos^="bottom"] {
  bottom: 16px;
}
.tos-root[data-pos^="top"] {
  top: 16px;
}
/* a preview sits in the flow */
.tos-root:not([data-pos]) {
  position: relative;
}

/* a card */
.tos-toast {
  position: absolute;
  inset-inline: 0;
  display: flex;
  align-items: center;
  gap: 12px;
  padding-inline: 14px 10px;
  border-radius: var(--ts-r-pop);
  background: var(--tos-card);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  transform-origin: 50% 100%;
  pointer-events: auto;
  touch-action: pan-y;
  cursor: grab;
}
.tos-root[data-pos^="top"] .tos-toast {
  transform-origin: 50% 0;
}
.tos-icon {
  display: grid;
  flex: none;
  color: var(--ts-fg-muted);
}
.tos-icon svg {
  width: 18px;
  height: 18px;
}
.tos-toast[data-kind="success"] .tos-icon {
  color: var(--ts-success);
}
.tos-toast[data-kind="error"] .tos-icon {
  color: var(--ts-danger);
}
.tos-toast[data-kind="warning"] .tos-icon {
  color: var(--ts-warning);
}

/* text: bold title over a muted one-line body, both clipped with an ellipsis */
.tos-text {
  display: flex;
  flex: 1 1 0;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.tos-text > * {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tos-text b {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: -.01em;
}
.tos-text span {
  font-size: 12px;
  color: var(--ts-fg-muted);
}

/* buttons */
.tos-act,
.tos-x {
  cursor: pointer;
  transition: scale 220ms var(--tos-out), background 160ms var(--tos-out), color 160ms var(--tos-out);
}
.tos-act {
  height: 30px;
  padding-inline: 10px;
  border-radius: 999px;
  background: var(--ts-fg);
  color: var(--ts-fg-inverse);
  font-size: 12px;
  font-weight: 500;
}
.tos-x {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  color: var(--ts-fg-faint);
}
.tos-x svg {
  width: 15px;
  height: 15px;
}
.tos-x:hover {
  background: rgba(var(--ts-fg-rgb), .06);
  color: var(--ts-fg);
}
.tos-act:active {
  scale: .94;
}
.tos-x:active {
  scale: .9;
}
.tos-act:focus-visible,
.tos-x:focus-visible {
  outline: 2px solid var(--ts-focus);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .tos-root {
    transition: none;
  }
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { Toaster } from "./Toaster";
import "./Toaster.css";

export default function Demo() {
  return <Toaster />;
}