Skip to content

Toast Notification Stack

A Sonner-style toast system with no dependencies: toasts fold into a deck, fan out on hover, focus or tap, and pause their progress lines while open. Drag to throw one away, success, error and info types, an action button, and a loading toast that becomes a success or error in place.

DOM + CSS0 deps1 file · 28 KB TSXreduced-motion aware

Freeminimalglass
Preview

Props

2200
No runtime dependencies
"use client";

/*
 * Toast stack: a Sonner-style notification system in one file, no dependencies.
 *
 * useToasts() returns pushToast, dismiss, update, promise and a <ToastStack />
 * to render once. Toasts pile up as a deck (older ones peek out behind the
 * newest), fan out into a list on hover, focus or tap, and every one carries a
 * progress line that pauses while the stack is open. Drag a toast sideways, or
 * toward the screen edge, to throw it away. A loading toast turns into a
 * success or an error in place.
 */

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type FocusEvent as ReactFocusEvent,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";

/* ---- Types -------------------------------------------------------------- */

export type ToastType = "default" | "success" | "error" | "info" | "warning" | "loading";

export type ToastAction = { label: string; onClick?: () => void };

export type ToastOptions = {
  type?: ToastType;
  /** A second, quieter line under the message. */
  description?: string;
  /** A button on the toast. Clicking it runs onClick and dismisses the toast. */
  action?: ToastAction;
  /** ms before this toast dismisses itself. 0 keeps it until dismissed. Overrides the hook's duration. */
  duration?: number;
  /** Reuse an id to update a toast that is still showing instead of adding another. */
  id?: number;
};

type ToastPosition = "bottom" | "top";
type ToastAlign = "center" | "left" | "right";

type Config = {
  duration: number;
  position: ToastPosition;
  align: ToastAlign;
  container: "viewport" | "parent";
  visibleToasts: number;
  limit: number;
  width: number;
};

type ToastRecord = {
  id: number;
  seq: number;
  message: string;
  description?: string;
  type: ToastType;
  action?: ToastAction;
  /** Lifetime in ms. Infinity means it stays until dismissed. */
  total: number;
  /** Time left. Counted down by the viewport's frame loop, never rendered directly. */
  remaining: number;
  removed: boolean;
  exit?: { x: number; y: number };
};

type Snapshot = { toasts: ToastRecord[]; config: Config };

type ResultText = string | { message: string; description?: string };

/* ---- Constants ---------------------------------------------------------- */

const EXIT_MS = 340;
const GAP = 10;
const PEEK = 14;
const FALLBACK_H = 64;
const EASE_OUT = "cubic-bezier(0.22, 1, 0.36, 1)";
const EASE_SPRING = "cubic-bezier(0.22, 1.12, 0.36, 1)";

const TONE: Record<ToastType, { fg: string; rgb: string }> = {
  default: { fg: "#e4e4e7", rgb: "228,228,231" },
  success: { fg: "#4ade80", rgb: "74,222,128" },
  error: { fg: "#f87171", rgb: "248,113,113" },
  info: { fg: "#8ab0ff", rgb: "138,176,255" },
  warning: { fg: "#fbbf24", rgb: "251,191,36" },
  loading: { fg: "#c4c4cc", rgb: "196,196,204" },
};

let toastId = 0;
let toastSeq = 0;

/* ---- Store -------------------------------------------------------------- */

function lifetime(type: ToastType, duration: number | undefined, fallback: number): number {
  if (duration !== undefined) return duration > 0 ? duration : Infinity;
  return type === "loading" ? Infinity : fallback;
}

function text(r: ResultText): { message: string; description?: string } {
  return typeof r === "string" ? { message: r } : r;
}

function createToastStore(initial: Config) {
  let snap: Snapshot = { toasts: [], config: initial };
  const serverSnap: Snapshot = snap;
  const subs = new Set<() => void>();

  function commit(next: Partial<Snapshot>) {
    snap = { ...snap, ...next };
    subs.forEach((f) => f());
  }

  function dismiss(id?: number, exit?: { x: number; y: number }) {
    const gone: number[] = [];
    const toasts = snap.toasts.map((t) => {
      if (t.removed || (id !== undefined && t.id !== id)) return t;
      gone.push(t.id);
      return { ...t, removed: true, exit };
    });
    if (gone.length === 0) return;
    commit({ toasts });
    window.setTimeout(() => {
      commit({ toasts: snap.toasts.filter((t) => !gone.includes(t.id)) });
    }, EXIT_MS);
  }

  function update(id: number, patch: ToastOptions & { message?: string }) {
    const prev = snap.toasts.find((t) => t.id === id && !t.removed);
    if (!prev) return;
    const type = patch.type ?? prev.type;
    const total = lifetime(type, patch.duration, snap.config.duration);
    const next: ToastRecord = {
      ...prev,
      message: patch.message ?? prev.message,
      description: "description" in patch ? patch.description : prev.description,
      action: "action" in patch ? patch.action : prev.action,
      type,
      total,
      remaining: total,
    };
    commit({ toasts: snap.toasts.map((t) => (t === prev ? next : t)) });
  }

  function push(message: string, options: ToastOptions = {}): number {
    const id = options.id ?? toastId++;
    if (snap.toasts.some((t) => t.id === id && !t.removed)) {
      update(id, { ...options, message });
      return id;
    }
    const type = options.type ?? "default";
    const total = lifetime(type, options.duration, snap.config.duration);
    const rec: ToastRecord = {
      id,
      seq: toastSeq++,
      message,
      description: options.description,
      type,
      action: options.action,
      total,
      remaining: total,
      removed: false,
    };
    commit({ toasts: [...snap.toasts, rec] });
    const live = snap.toasts.filter((t) => !t.removed);
    for (let k = 0; k < live.length - snap.config.limit; k++) {
      const old = live[k];
      if (old) dismiss(old.id);
    }
    return id;
  }

  function promise<T>(
    work: Promise<T> | (() => Promise<T>),
    msgs: {
      loading: ResultText;
      success: ResultText | ((value: T) => ResultText);
      error: ResultText | ((error: unknown) => ResultText);
    },
    options: Omit<ToastOptions, "type" | "id"> = {},
  ): number {
    const first = text(msgs.loading);
    const id = push(first.message, { ...options, type: "loading", description: first.description });
    const run = typeof work === "function" ? work() : work;
    run.then(
      (value) => {
        const r = text(typeof msgs.success === "function" ? msgs.success(value) : msgs.success);
        update(id, { type: "success", message: r.message, description: r.description });
      },
      (err: unknown) => {
        const r = text(typeof msgs.error === "function" ? msgs.error(err) : msgs.error);
        update(id, { type: "error", message: r.message, description: r.description });
      },
    );
    return id;
  }

  /** Count every running timer down by dt ms; toasts that reach zero dismiss themselves. */
  function tick(dt: number) {
    const expired: number[] = [];
    for (const t of snap.toasts) {
      if (t.removed || !Number.isFinite(t.total)) continue;
      t.remaining -= dt;
      if (t.remaining <= 0) expired.push(t.id);
    }
    expired.forEach((id) => dismiss(id));
  }

  return {
    subscribe(cb: () => void) {
      subs.add(cb);
      return () => {
        subs.delete(cb);
      };
    },
    getSnapshot: () => snap,
    getServerSnapshot: () => serverSnap,
    configure(config: Config) {
      const c = snap.config;
      if (
        c.duration === config.duration &&
        c.position === config.position &&
        c.align === config.align &&
        c.container === config.container &&
        c.visibleToasts === config.visibleToasts &&
        c.limit === config.limit &&
        c.width === config.width
      )
        return;
      commit({ config });
    },
    push,
    update,
    dismiss,
    promise,
    tick,
  };
}

type Store = ReturnType<typeof createToastStore>;

/* ---- Hook --------------------------------------------------------------- */

export function useToasts({
  duration = 2200,
  position = "bottom",
  align = "center",
  container = "viewport",
  visibleToasts = 3,
  limit = 8,
  width = 360,
}: {
  /** ms before a toast auto-dismisses. */
  duration?: number;
  /** Which edge the stack grows from. */
  position?: "bottom" | "top";
  /** Where the stack sits along that edge. */
  align?: "center" | "left" | "right";
  /** "viewport" pins the stack to the window; "parent" pins it inside the nearest positioned ancestor. */
  container?: "viewport" | "parent";
  /** How many toasts show in the folded deck. */
  visibleToasts?: number;
  /** The most toasts kept at once; the oldest is dismissed past this. */
  limit?: number;
  /** Toast width in px (never wider than the screen). */
  width?: number;
} = {}) {
  const [store] = useState(() =>
    createToastStore({ duration, position, align, container, visibleToasts, limit, width }),
  );
  useEffect(() => {
    store.configure({ duration, position, align, container, visibleToasts, limit, width });
  }, [store, duration, position, align, container, visibleToasts, limit, width]);
  const [ToastStack] = useState(() => {
    const Stack = () => <Viewport store={store} />;
    return Stack;
  });
  return {
    pushToast: store.push,
    dismiss: store.dismiss,
    update: store.update,
    promise: store.promise,
    ToastStack,
  };
}

/* ---- Styles ------------------------------------------------------------- */

const CSS = [
  ".ts-root{position:absolute;height:0;z-index:60;pointer-events:none;outline:none;font-family:inherit}",
  ".ts-root[data-fixed='true']{position:fixed}",
  ".ts-item{position:absolute;left:0;right:0;margin-inline:auto;list-style:none;pointer-events:auto;touch-action:none;user-select:none;-webkit-user-select:none;will-change:transform,opacity}",
  ".ts-root[data-align='left'] .ts-item{margin-left:0;margin-right:auto}",
  ".ts-root[data-align='right'] .ts-item{margin-left:auto;margin-right:0}",
  ".ts-item[data-dead='true']{pointer-events:none}",
  ".ts-item::after{content:'';position:absolute;left:0;right:0;height:" + (GAP + 20) + "px}",
  ".ts-root[data-pos='bottom'] .ts-item::after{bottom:100%}",
  ".ts-root[data-pos='top'] .ts-item::after{top:100%}",
  ".ts-card{position:relative;height:100%;overflow:hidden;border-radius:16px;border:1px solid rgba(255,255,255,.1);color:#f5f5f6;background:linear-gradient(180deg,rgba(32,33,38,.94),rgba(17,18,21,.95));-webkit-backdrop-filter:blur(18px) saturate(1.5);backdrop-filter:blur(18px) saturate(1.5);box-shadow:inset 0 1px 0 rgba(255,255,255,.07),0 1px 2px rgba(0,0,0,.4),0 14px 34px -10px rgba(0,0,0,.65),0 30px 60px -24px rgba(0,0,0,.5)}",
  ".ts-tint{position:absolute;inset:0;pointer-events:none;border-radius:inherit}",
  ".ts-glow{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity .3s ease;background:radial-gradient(200px circle at var(--mx,50%) var(--my,0%),rgba(255,255,255,.075),transparent 70%)}",
  ".ts-item:hover .ts-glow{opacity:1}",
  ".ts-inner{position:relative;display:flex;align-items:center;gap:12px;padding:13px 12px 13px 14px;transition:opacity .3s ease}",
  ".ts-inner[data-hide='true']{opacity:0}",
  ".ts-icon{flex:none;display:grid;place-items:center;width:24px;height:24px;border-radius:999px;align-self:flex-start;margin-top:-1px}",
  ".ts-icon svg{width:15px;height:15px;overflow:visible}",
  ".ts-draw{stroke-dasharray:1;stroke-dashoffset:1;animation:ts-draw .5s .12s cubic-bezier(.3,.7,.3,1) forwards}",
  ".ts-pop{transform-origin:center;animation:ts-pop .5s cubic-bezier(.3,1.6,.5,1) both}",
  ".ts-spin{transform-origin:10px 10px;animation:ts-spin .8s linear infinite}",
  ".ts-body{min-width:0;flex:1}",
  ".ts-msg{font-size:13.5px;line-height:18px;font-weight:560;letter-spacing:-.006em;color:#f5f5f6;animation:ts-text .32s " + EASE_OUT + " both;overflow-wrap:anywhere}",
  ".ts-desc{margin-top:2px;font-size:12.5px;line-height:17px;font-weight:400;color:#a0a2ab;animation:ts-text .32s .05s " + EASE_OUT + " both;overflow-wrap:anywhere}",
  ".ts-btn{flex:none;appearance:none;cursor:pointer;display:grid;place-items:center;border:0;font:inherit;color:#f5f5f6;transition:background-color .18s ease,color .18s ease,opacity .18s ease,transform .18s ease}",
  ".ts-btn:active{transform:scale(.94)}",
  ".ts-act{height:28px;padding:0 12px;border-radius:999px;font-size:12px;font-weight:600;background:rgba(255,255,255,.11);box-shadow:inset 0 0 0 1px rgba(255,255,255,.08)}",
  ".ts-act:hover{background:rgba(255,255,255,.2)}",
  ".ts-close{width:26px;height:26px;border-radius:8px;background:transparent;color:#8d8f98;opacity:0}",
  ".ts-close:hover{background:rgba(255,255,255,.1);color:#f5f5f6}",
  ".ts-item:hover .ts-close,.ts-item:focus-within .ts-close{opacity:1}",
  ".ts-btn:focus-visible{outline:2px solid #fff!important;outline-offset:2px!important;opacity:1}",
  ".ts-act:focus-visible{border-radius:999px!important}",
  ".ts-close:focus-visible{border-radius:8px!important}",
  ".ts-bar{position:absolute;left:0;right:0;bottom:0;height:2px;transform-origin:0 50%;opacity:.5;transition:opacity .25s ease}",
  ".ts-root[data-paused='true'] .ts-bar{opacity:1}",
  "@media (pointer:coarse){.ts-close{opacity:1}.ts-act{height:40px;padding:0 16px}.ts-close{width:40px;height:40px}.ts-inner{padding-block:10px}}",
  "@keyframes ts-draw{to{stroke-dashoffset:0}}",
  "@keyframes ts-pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}",
  "@keyframes ts-spin{to{transform:rotate(360deg)}}",
  "@keyframes ts-text{from{opacity:0;transform:translateY(4px);filter:blur(3px)}to{opacity:1;transform:none;filter:none}}",
  "@media (prefers-reduced-motion:reduce){.ts-item{transition-property:opacity!important;transition-duration:.2s!important}.ts-msg,.ts-desc,.ts-pop{animation:none}.ts-draw{animation:none;stroke-dashoffset:0}.ts-spin{animation-duration:2.4s}}",
].join("\n");

/* ---- Pieces ------------------------------------------------------------- */

function Glyph({ type }: { type: ToastType }) {
  const tone = TONE[type];
  return (
    <span
      className="ts-icon ts-pop"
      key={type}
      style={{ color: tone.fg, background: "rgba(" + tone.rgb + ",.14)", boxShadow: "inset 0 0 0 1px rgba(" + tone.rgb + ",.3)" }}
      aria-hidden="true"
    >
      <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
        {type === "success" && <path className="ts-draw" pathLength={1} d="M5.5 10.4l3 3 6-6.4" />}
        {type === "error" && <path className="ts-draw" pathLength={1} d="M6.5 6.5l7 7M13.5 6.5l-7 7" />}
        {type === "info" && (
          <>
            <path d="M10 9v5" />
            <circle cx="10" cy="5.8" r="0.6" fill="currentColor" />
          </>
        )}
        {type === "warning" && (
          <>
            <path d="M10 5.5v5.4" />
            <circle cx="10" cy="14.4" r="0.6" fill="currentColor" />
          </>
        )}
        {type === "loading" && (
          <g className="ts-spin">
            <circle cx="10" cy="10" r="6.5" strokeOpacity="0.25" />
            <path d="M10 3.5a6.5 6.5 0 0 1 6.5 6.5" />
          </g>
        )}
        {type === "default" && <circle cx="10" cy="10" r="2.4" fill="currentColor" stroke="none" />}
      </svg>
    </span>
  );
}

type ItemProps = {
  t: ToastRecord;
  store: Store;
  dir: 1 | -1;
  offset: number;
  scale: number;
  height: number | undefined;
  foldedHeight: number | null;
  folded: boolean;
  beyond: boolean;
  width: number;
  onHeight: (id: number, h: number) => void;
  registerBar: (id: number, el: HTMLElement | null) => void;
  onDragActive: (id: number | null) => void;
  onTap: () => void;
  refocus: () => void;
};

function ToastItem({
  t,
  store,
  dir,
  offset,
  scale,
  height,
  foldedHeight,
  folded,
  beyond,
  width,
  onHeight,
  registerBar,
  onDragActive,
  onTap,
  refocus,
}: ItemProps) {
  const liRef = useRef<HTMLLIElement>(null);
  const innerRef = useRef<HTMLDivElement>(null);
  const [mounted, setMounted] = useState(false);
  const [drag, setDrag] = useState({ x: 0, y: 0, active: false });
  const gesture = useRef<{
    x0: number;
    y0: number;
    k: number;
    lastX: number;
    lastT: number;
    vx: number;
    moved: boolean;
    touch: boolean;
  } | null>(null);
  const outward = -dir;

  useEffect(() => {
    let b = 0;
    const a = requestAnimationFrame(() => {
      b = requestAnimationFrame(() => setMounted(true));
    });
    return () => {
      cancelAnimationFrame(a);
      cancelAnimationFrame(b);
    };
  }, []);

  useEffect(() => {
    const el = innerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => onHeight(t.id, el.offsetHeight + 2));
    ro.observe(el);
    return () => ro.disconnect();
  }, [t.id, onHeight]);

  useEffect(() => {
    liRef.current?.toggleAttribute("inert", folded || t.removed);
  }, [folded, t.removed]);

  function leave(exit: { x: number; y: number }) {
    store.dismiss(t.id, exit);
    refocus();
  }

  function onPointerDown(e: ReactPointerEvent<HTMLLIElement>) {
    if (e.button !== 0 || t.removed) return;
    if ((e.target as HTMLElement).closest("button")) return;
    const li = e.currentTarget;
    const rect = li.getBoundingClientRect();
    gesture.current = {
      x0: e.clientX,
      y0: e.clientY,
      k: rect.width > 0 ? li.offsetWidth / rect.width : 1,
      lastX: e.clientX,
      lastT: e.timeStamp,
      vx: 0,
      moved: false,
      touch: e.pointerType === "touch",
    };
    li.setPointerCapture(e.pointerId);
  }

  function onPointerMove(e: ReactPointerEvent<HTMLLIElement>) {
    const g = gesture.current;
    const li = e.currentTarget;
    if (!g) {
      if (e.pointerType === "mouse") {
        const r = li.getBoundingClientRect();
        const k = r.width > 0 ? li.offsetWidth / r.width : 1;
        li.style.setProperty("--mx", (e.clientX - r.left) * k + "px");
        li.style.setProperty("--my", (e.clientY - r.top) * k + "px");
      }
      return;
    }
    const dx = (e.clientX - g.x0) * g.k;
    const dy = (e.clientY - g.y0) * g.k;
    if (!g.moved && Math.hypot(dx, dy) < 5) return;
    if (!g.moved) {
      g.moved = true;
      onDragActive(t.id);
    }
    const dt = Math.max(1, e.timeStamp - g.lastT);
    g.vx = g.vx * 0.6 + (((e.clientX - g.lastX) * g.k) / dt) * 0.4;
    g.lastX = e.clientX;
    g.lastT = e.timeStamp;
    const out = dy * outward;
    setDrag({ x: dx, y: out > 0 ? dy : dy * 0.12, active: true });
  }

  function end(e: ReactPointerEvent<HTMLLIElement>, cancelled: boolean) {
    const g = gesture.current;
    gesture.current = null;
    if (!g) return;
    if (!g.moved) {
      if (!cancelled && g.touch) onTap();
      return;
    }
    onDragActive(null);
    const dx = (e.clientX - g.x0) * g.k;
    const out = (e.clientY - g.y0) * g.k * outward;
    const fast = Math.abs(g.vx) > 0.55 && Math.abs(dx) > 30;
    if (!cancelled && (Math.abs(dx) > 96 || out > 56 || fast)) {
      if (Math.abs(dx) >= out) leave({ x: Math.sign(dx || 1) * (width + 60), y: drag.y });
      else leave({ x: dx, y: outward * 90 });
      setDrag({ x: 0, y: 0, active: false });
      return;
    }
    setDrag({ x: 0, y: 0, active: false });
  }

  function onKeyDown(e: ReactKeyboardEvent<HTMLLIElement>) {
    if (e.key === "Escape") {
      e.stopPropagation();
      leave({ x: 0, y: outward * 24 });
    }
  }

  const y = dir * offset;
  const hh = height ?? FALLBACK_H;
  let transform: string;
  let opacity = 1;
  let transition: string;
  if (t.removed) {
    const ex = t.exit ?? { x: 0, y: outward * 24 };
    transform = "translate3d(" + ex.x + "px," + (y + ex.y) + "px,0) scale(0.95)";
    opacity = 0;
    transition = "transform " + EXIT_MS + "ms cubic-bezier(0.4,0,0.9,0.5), opacity 260ms ease, height 0s";
  } else if (!mounted) {
    transform = "translate3d(0," + (y + outward * (hh * 0.7 + 10)) + "px,0) scale(0.94)";
    opacity = 0;
    transition = "none";
  } else if (drag.active) {
    transform = "translate3d(" + drag.x + "px," + (y + drag.y) + "px,0) scale(" + scale + ")";
    opacity = 1 - Math.min(0.55, Math.abs(drag.x) / 340);
    transition = "height 0.45s " + EASE_OUT;
  } else {
    transform = "translate3d(0," + y + "px,0) scale(" + scale + ")";
    opacity = beyond ? 0 : 1;
    transition =
      "transform 0.55s " + EASE_SPRING + ", opacity 0.35s ease, height 0.45s " + EASE_OUT;
  }
  const tone = TONE[t.type];
  const finite = Number.isFinite(t.total);
  const shownH = folded && foldedHeight !== null ? foldedHeight : height;

  return (
    <li
      ref={liRef}
      className="ts-item"
      data-dead={t.removed || beyond ? "true" : "false"}
      role={t.type === "error" ? "alert" : undefined}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={(e) => end(e, false)}
      onPointerCancel={(e) => end(e, true)}
      onKeyDown={onKeyDown}
      style={{
        [dir === -1 ? "bottom" : "top"]: 0,
        width: "min(" + width + "px, 100%)",
        height: shownH,
        transform,
        opacity,
        transition,
        transformOrigin: dir === -1 ? "50% 100%" : "50% 0%",
        zIndex: 1000 - Math.round(offset),
        cursor: drag.active ? "grabbing" : undefined,
      }}
    >
      <div className="ts-card">
        <div
          className="ts-tint"
          style={{ background: "radial-gradient(240px 110px at 0% 0%, rgba(" + tone.rgb + ",.13), transparent 72%)" }}
        />
        <div className="ts-glow" />
        <div ref={innerRef} className="ts-inner" data-hide={folded ? "true" : "false"}>
          <Glyph type={t.type} />
          <div className="ts-body">
            <div className="ts-msg" key={t.type + t.message}>
              {t.message}
            </div>
            {t.description ? (
              <div className="ts-desc" key={t.type + t.description}>
                {t.description}
              </div>
            ) : null}
          </div>
          {t.action ? (
            <button
              type="button"
              className="ts-btn ts-act"
              onClick={() => {
                t.action?.onClick?.();
                leave({ x: 0, y: outward * 24 });
              }}
            >
              {t.action.label}
            </button>
          ) : null}
          <button
            type="button"
            className="ts-btn ts-close"
            aria-label={"Dismiss notification: " + t.message}
            onClick={() => leave({ x: 0, y: outward * 24 })}
          >
            <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true">
              <path d="M2.5 2.5l7 7M9.5 2.5l-7 7" />
            </svg>
          </button>
        </div>
        {finite ? (
          <div
            ref={(el) => registerBar(t.id, el)}
            className="ts-bar"
            style={{ background: "linear-gradient(90deg, rgba(" + tone.rgb + ",.9), rgba(" + tone.rgb + ",.35))" }}
          />
        ) : null}
      </div>
    </li>
  );
}

/* ---- Viewport ----------------------------------------------------------- */

function Viewport({ store }: { store: Store }) {
  const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
  const { toasts, config } = snap;
  const rootRef = useRef<HTMLOListElement>(null);
  const bars = useRef(new Map<number, HTMLElement>());
  const pausedRef = useRef(false);
  const [hovered, setHovered] = useState(false);
  const [touchOpen, setTouchOpen] = useState(false);
  const [focused, setFocused] = useState(false);
  const [dragId, setDragId] = useState<number | null>(null);
  const [heights, setHeights] = useState<Record<number, number>>({});

  const live = toasts.filter((t) => !t.removed);
  const liveCount = live.length;
  if (liveCount === 0 && (hovered || touchOpen || focused)) {
    setHovered(false);
    setTouchOpen(false);
    setFocused(false);
  }
  const expanded = liveCount > 0 && (hovered || touchOpen || focused);
  const paused = expanded || dragId !== null;
  const timed = live.some((t) => Number.isFinite(t.total));

  useEffect(() => {
    pausedRef.current = paused;
  }, [paused]);

  useEffect(() => {
    if (!timed) return;
    let raf = 0;
    let last = -1;
    const frame = (now: number) => {
      if (last >= 0) {
        const dt = Math.min(64, now - last);
        if (!pausedRef.current) store.tick(dt);
      }
      last = now;
      for (const t of store.getSnapshot().toasts) {
        const el = bars.current.get(t.id);
        if (el && Number.isFinite(t.total)) {
          el.style.transform = "scaleX(" + Math.max(0, Math.min(1, t.remaining / t.total)) + ")";
        }
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [timed, store]);

  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.altKey && e.code === "KeyT") {
        e.preventDefault();
        rootRef.current?.focus();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  useEffect(() => {
    if (!touchOpen) return;
    const onDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) setTouchOpen(false);
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [touchOpen]);

  const onHeight = useCallback((id: number, h: number) => {
    setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
  }, []);
  const registerBar = useCallback((id: number, el: HTMLElement | null) => {
    if (el) bars.current.set(id, el);
    else bars.current.delete(id);
  }, []);
  const refocus = useCallback(() => {
    const root = rootRef.current;
    if (root && root.contains(document.activeElement)) root.focus();
  }, []);
  const onTap = useCallback(() => setTouchOpen((v) => !v), []);

  const dir: 1 | -1 = config.position === "bottom" ? -1 : 1;
  const hOf = (id: number) => heights[id] ?? FALLBACK_H;
  const front = live[live.length - 1];
  const frontH = front ? hOf(front.id) : FALLBACK_H;
  const ordered = [...toasts].sort((a, b) => b.seq - a.seq);
  const viewport = config.container === "viewport";
  const edge = "16px";
  const vEdge = viewport ? "calc(" + edge + " + env(safe-area-inset-" + (dir === -1 ? "bottom" : "top") + ", 0px))" : "max(" + edge + ", 9%)";

  return (
    <>
      <style>{CSS}</style>
      <ol
        ref={rootRef}
        className="ts-root"
        aria-label="Notifications (Alt+T)"
        aria-live="polite"
        aria-relevant="additions text"
        tabIndex={-1}
        data-fixed={viewport ? "true" : "false"}
        data-pos={config.position}
        data-align={config.align}
        data-paused={paused ? "true" : "false"}
        onPointerEnter={(e) => {
          if (e.pointerType !== "touch") setHovered(true);
        }}
        onPointerLeave={(e) => {
          if (e.pointerType !== "touch") setHovered(false);
        }}
        onFocus={(e: ReactFocusEvent<HTMLOListElement>) => {
          setFocused(e.target.matches(":focus-visible"));
        }}
        onBlur={(e: ReactFocusEvent<HTMLOListElement>) => {
          if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false);
        }}
        style={{
          left: edge,
          right: edge,
          [dir === -1 ? "bottom" : "top"]:
            vEdge,
        }}
      >
        {ordered.map((t) => {
          const newer = live.filter((x) => x.seq > t.seq);
          const i = newer.length;
          const expandedOffset = newer.reduce((s, x) => s + hOf(x.id) + GAP, 0);
          const offset = expanded ? expandedOffset : i * PEEK;
          const scale = expanded ? 1 : Math.max(0.8, 1 - i * 0.05);
          const folded = !expanded && i > 0 && !t.removed;
          return (
            <ToastItem
              key={t.id}
              t={t}
              store={store}
              dir={dir}
              offset={offset}
              scale={scale}
              height={heights[t.id]}
              foldedHeight={folded ? frontH : null}
              folded={folded}
              beyond={!expanded && i >= config.visibleToasts && !t.removed}
              width={config.width}
              onHeight={onHeight}
              registerBar={registerBar}
              onDragActive={setDragId}
              onTap={onTap}
              refocus={refocus}
            />
          );
        })}
      </ol>
    </>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this pattern

A toast system in one file with nothing to install. useToasts({ duration, position }) still returns pushToast and a ToastStack to render once, and pushToast(message) still works with a bare string. Added on top: pushToast(message, { type, description, action, duration, id }) with success, error, info, warning and loading types, plus promise(), update() and dismiss(). Toasts stack as a deck: the newest sits in front and up to three older ones peek out behind it, scaled down 5% per level. Hover, keyboard focus or a tap on a phone fans the deck into a list, and every progress line pauses while it is open (timers are counted down by a single frame loop, so a toast keeps exactly the time it had left). Drag a toast sideways or toward the screen edge, release past 96px or with a flick, and it is thrown off; otherwise it springs back. A loading toast, or promise(), turns into a success or error in place, with the icon redrawn and the text blurring in. Heights are measured, so toasts of any length reflow smoothly. More optional options on the hook: align, container (viewport or parent), visibleToasts, limit and width. Motion is CSS transitions on a spring-like cubic-bezier. The toast surface is its own dark glass and does not read your theme.

Curator’s note

Rebuilt in October without Framer Motion: the useToasts hook keeps its shape, and every toast now folds into a deck, fans out on hover and carries its own progress line.

Pairs well with

Pro