A toast stack that behaves like a Newton's cradle: a new toast swings in and strikes the queue, a fast domino of shunts carries the impact down the stack, and past capacity that momentum visibly ejects the oldest toast off the far end.
npx shadcn add https://design.helpmarq.com /r/toast-newton-cradle.jsonregistry/loud/toast-newton-cradle/component.tsx"use client";
import {
forwardRef,
useCallback,
useEffect,
useId,
useImperativeHandle,
useRef,
useState,
type ReactNode,
} from "react";
// ---------------------------------------------------------------------------
// ImpulseCradle — a toast stack that behaves like a Newton's cradle. A new
// toast swings in from the top with an accelerating (ease-in) entrance and
// "strikes" the queue; the impact travels down through every already-queued
// toast as a fast domino of 2-3px shunts, staggered 35ms apart, so the eye
// reads how deep the stack is without counting anything. Under capacity that
// impulse simply dissipates — the last toast shunts and returns. At capacity
// the same impulse instead carries the oldest toast off the far (bottom) end:
// it inherits the shunt's momentum and departs with an ease-out arc, a slight
// rotation, and a fade — conservation of notifications, visibly enforced.
// Pure DOM transforms via the Web Animations API, tokens only, no canvas.
// ---------------------------------------------------------------------------
export type ImpulseSeverity = "info" | "error";
export interface ImpulseToastInput {
severity?: ImpulseSeverity;
title: string;
message?: string;
/** ms before auto-dismiss; 0 disables; defaults to the stack `duration` */
duration?: number;
}
export interface ImpulseCradleHandle {
/** swing a new toast into the stack, returns its id */
push: (toast: ImpulseToastInput) => string;
dismiss: (id: string) => void;
clear: () => void;
}
type ToastRec = {
id: string;
severity: ImpulseSeverity;
title: string;
message?: string;
duration: number;
};
type HistoryReason = "dismissed" | "expired" | "evicted";
type HistoryRec = ToastRec & { reason: HistoryReason; at: number };
type TimerEntry = { handle: number | null; deadline: number; remaining: number };
type PauseReason = "hover" | "focus";
const SHUNT_STAGGER = 35; // ms between each toast's impulse pulse
const SHUNT_MS = 60;
const SHUNT_PX = 3;
const EJECT_MS = 340;
const ENTER_MS = 200;
const EXIT_MS = 150;
const EASE_IN = "cubic-bezier(.55,.06,.68,.19)"; // accelerating, released-ball entrance
const EASE_OUT = "cubic-bezier(0.16,1,0.3,1)"; // decelerating, matches the eject's inherited velocity
const ICONS: Record<ImpulseSeverity, ReactNode> = {
error: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" />
<path d="m15 9-6 6M9 9l6 6" />
</svg>
),
info: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.5 2.5 4.5-5" />
</svg>
),
};
function CardShell({ toast, onDismiss }: { toast: ToastRec; onDismiss: () => void }) {
return (
<div className="flex items-start gap-2.5 py-2.5 pl-3 pr-1.5">
<span className="mt-0.5 shrink-0 text-muted">{ICONS[toast.severity]}</span>
<div className="min-w-0 flex-1">
<p
className={`truncate text-[13px] leading-tight text-foreground ${
toast.severity === "error" ? "font-semibold" : "font-medium"
}`}
>
{toast.title}
</p>
{toast.message ? (
<p className="mt-0.5 truncate font-mono text-[11px] leading-tight text-muted">
{toast.message}
</p>
) : null}
</div>
<button
type="button"
aria-label={`Dismiss: ${toast.title}`}
onClick={onDismiss}
className="shrink-0 cursor-pointer rounded-sm p-1 text-muted transition-colors duration-150 hover:bg-foreground/10 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
);
}
export const ImpulseCradle = forwardRef<
ImpulseCradleHandle,
{
/** how many toasts the stack shows before the impulse starts ejecting */
maxVisible?: number;
/** ms before a toast auto-dismisses (paused on hover/focus); 0 disables */
duration?: number;
/** toasts present at mount */
initial?: ImpulseToastInput[];
className?: string;
"aria-label"?: string;
}
>(function ImpulseCradle(
{
maxVisible = 4,
duration = 5000,
initial,
className = "w-full max-w-sm",
"aria-label": ariaLabel = "Notifications",
},
ref
) {
const cap = Math.max(1, maxVisible);
const regionRef = useRef<HTMLDivElement>(null);
const historyPanelId = useId();
const seqRef = useRef(0);
const liveSeqRef = useRef(0);
const toastsRef = useRef<ToastRec[]>([]);
const timersRef = useRef<Map<string, TimerEntry>>(new Map());
const pauseReasonsRef = useRef<Map<string, Set<PauseReason>>>(new Map());
const cardElsRef = useRef<Map<string, HTMLDivElement>>(new Map());
const enteredRef = useRef<Set<string>>(new Set());
const exitingIdsRef = useRef<Set<string>>(new Set());
const evictDelayRef = useRef<Map<string, number>>(new Map());
const evictAttachedRef = useRef<Set<string>>(new Set());
const [toasts, setToasts] = useState<ToastRec[]>(() =>
(initial ?? []).map((t, i) => ({
id: `ic-init-${i}`,
severity: t.severity ?? "info",
title: t.title,
message: t.message,
duration: t.duration ?? duration,
}))
);
toastsRef.current = toasts;
const [evicting, setEvicting] = useState<ToastRec[]>([]);
const [history, setHistory] = useState<HistoryRec[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const [reduced, setReduced] = useState(false);
const [live, setLive] = useState<{ text: string; level: "polite" | "assertive"; seq: number }>({
text: "",
level: "polite",
seq: -1,
});
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const announce = useCallback((text: string, level: "polite" | "assertive") => {
setLive({ text, level, seq: ++liveSeqRef.current });
}, []);
const pushHistory = useCallback((t: ToastRec, reason: HistoryReason) => {
setHistory((prev) => [{ ...t, reason, at: Date.now() }, ...prev].slice(0, 12));
}, []);
const clearTimer = useCallback((id: string) => {
const entry = timersRef.current.get(id);
if (entry?.handle !== null && entry) clearTimeout(entry.handle);
timersRef.current.delete(id);
pauseReasonsRef.current.delete(id);
}, []);
const dismiss = useCallback(
(id: string, reason: HistoryReason = "dismissed") => {
if (exitingIdsRef.current.has(id)) return;
const t = toastsRef.current.find((x) => x.id === id);
if (!t) return;
exitingIdsRef.current.add(id);
clearTimer(id);
const el = cardElsRef.current.get(id);
const finish = () => {
exitingIdsRef.current.delete(id);
cardElsRef.current.delete(id);
enteredRef.current.delete(id);
setToasts((prev) => prev.filter((x) => x.id !== id));
pushHistory(t, reason);
};
if (el) {
const anim = el.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: EXIT_MS,
easing: "ease-out",
fill: "forwards",
});
anim.finished.catch(() => {}).finally(finish);
} else {
finish();
}
},
[clearTimer, pushHistory]
);
const armTimer = useCallback(
(t: ToastRec) => {
if (t.duration <= 0) return;
timersRef.current.set(t.id, {
handle: window.setTimeout(() => dismiss(t.id, "expired"), t.duration),
deadline: Date.now() + t.duration,
remaining: t.duration,
});
},
[dismiss]
);
const pauseTimer = useCallback((id: string, reason: PauseReason) => {
const reasons = pauseReasonsRef.current.get(id) ?? new Set<PauseReason>();
reasons.add(reason);
pauseReasonsRef.current.set(id, reasons);
const entry = timersRef.current.get(id);
if (entry?.handle !== null && entry) {
clearTimeout(entry.handle);
entry.handle = null;
entry.remaining = Math.max(0, entry.deadline - Date.now());
}
}, []);
const resumeTimer = useCallback(
(id: string, reason: PauseReason) => {
const reasons = pauseReasonsRef.current.get(id);
if (reasons) {
reasons.delete(reason);
if (reasons.size > 0) return;
}
const entry = timersRef.current.get(id);
if (!entry || entry.handle !== null) return;
const ms = Math.max(400, entry.remaining);
entry.deadline = Date.now() + ms;
entry.handle = window.setTimeout(() => dismiss(id, "expired"), ms);
},
[dismiss]
);
const finalizeEvict = useCallback(
(t: ToastRec) => {
evictAttachedRef.current.delete(t.id);
cardElsRef.current.delete(t.id);
setEvicting((prev) => prev.filter((x) => x.id !== t.id));
pushHistory(t, "evicted");
},
[pushHistory]
);
// entrance: every freshly-mounted toast card plays this exactly once — by
// construction the only card that ever mounts fresh is the newest arrival,
// since surviving cards keep their DOM identity (same React key) and just
// ride out the shunt in place instead of remounting.
const attachCard = useCallback(
(id: string, el: HTMLDivElement | null) => {
if (!el) return;
cardElsRef.current.set(id, el);
if (enteredRef.current.has(id)) return;
enteredRef.current.add(id);
if (reduced) {
el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: EXIT_MS, easing: "ease-out" });
} else {
el.animate(
[
{ transform: "translateY(-26px) scale(0.97)", opacity: 0 },
{ transform: "translateY(0) scale(1)", opacity: 1 },
],
{ duration: ENTER_MS, easing: EASE_IN }
);
}
},
[reduced]
);
// eviction: the oldest toast, bumped out of the visible cap, keeps
// rendering (in an inert overlay list) just long enough to play its
// departure, inheriting the same stagger slot the shunt cascade would have
// given it had it survived.
const attachEvicting = useCallback(
(t: ToastRec, el: HTMLDivElement | null) => {
if (!el || evictAttachedRef.current.has(t.id)) return;
evictAttachedRef.current.add(t.id);
const delay = evictDelayRef.current.get(t.id) ?? 0;
const anim = reduced
? el.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: EXIT_MS,
easing: "ease-out",
fill: "forwards",
})
: el.animate(
[
{ transform: "translateY(0px) rotate(0deg)", opacity: 1 },
{ transform: "translateY(10px) rotate(2deg)", opacity: 1, offset: 0.35 },
{ transform: "translateY(46px) rotate(9deg)", opacity: 0 },
],
{ duration: EJECT_MS, delay, easing: EASE_OUT, fill: "forwards" }
);
anim.finished
.catch(() => {})
.finally(() => finalizeEvict(t));
},
[reduced, finalizeEvict]
);
const push = useCallback(
(input: ImpulseToastInput): string => {
const id = `ic-${++seqRef.current}`;
const severity: ImpulseSeverity = input.severity ?? "info";
const rec: ToastRec = {
id,
severity,
title: input.title,
message: input.message,
duration: input.duration ?? duration,
};
const prev = toastsRef.current;
const overCap = prev.length >= cap;
const kept = overCap ? prev.slice(0, Math.max(0, cap - 1)) : prev;
const ejected = overCap ? prev[prev.length - 1] : null;
const next = [rec, ...kept];
toastsRef.current = next;
setToasts(next);
if (ejected) {
clearTimer(ejected.id);
evictDelayRef.current.set(ejected.id, kept.length * SHUNT_STAGGER);
setEvicting((ev) => [...ev, ejected]);
}
armTimer(rec);
announce(
`${severity === "error" ? "Error" : "Notification"}: ${rec.title}${
rec.message ? `. ${rec.message}` : ""
}`,
severity === "error" ? "assertive" : "polite"
);
// the impulse cascade — every toast that survives the push (i.e. every
// toast except the new arrival) gets a 3px shunt toward the far end,
// staggered 35ms deeper per position, then springs back. The ejected
// toast plays the matching (but non-returning) motion in attachEvicting.
if (!reduced) {
requestAnimationFrame(() => {
kept.forEach((t, i) => {
const el = cardElsRef.current.get(t.id);
el?.animate(
[
{ transform: "translateY(0px)" },
{ transform: `translateY(${SHUNT_PX}px)`, offset: 0.4 },
{ transform: "translateY(0px)" },
],
{ duration: SHUNT_MS, delay: (i + 1) * SHUNT_STAGGER, easing: "ease-out" }
);
});
});
}
return id;
},
[cap, duration, armTimer, announce, clearTimer, reduced]
);
const clear = useCallback(() => {
for (const t of toastsRef.current) dismiss(t.id);
}, [dismiss]);
useImperativeHandle(ref, () => ({ push, dismiss, clear }), [push, dismiss, clear]);
// arm timers for `initial` toasts seeded at mount (push() handles its own)
useEffect(() => {
for (const t of toastsRef.current) {
if (!timersRef.current.has(t.id)) armTimer(t);
}
// deliberately mount-only: push() arms its own toasts as they arrive
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// teardown: every pending timer dies with the component
useEffect(() => {
const timers = timersRef.current;
return () => {
for (const entry of timers.values()) if (entry.handle !== null) clearTimeout(entry.handle);
timers.clear();
};
}, []);
// F6 (pane-jump convention): send focus straight to the toast region —
// the newest toast if one is present, otherwise the region landmark itself.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "F6") return;
const root = regionRef.current;
if (!root) return;
e.preventDefault();
const firstToast = root.querySelector<HTMLElement>("[data-ic-toast]");
(firstToast ?? root).focus();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<div className={`relative ${className}`}>
<div
ref={regionRef}
role="region"
aria-label={ariaLabel}
tabIndex={-1}
className="flex flex-col gap-2 outline-none"
>
{toasts.map((t) => (
<div
key={t.id}
ref={(el) => attachCard(t.id, el)}
data-ic-toast
role={t.severity === "error" ? "alert" : "status"}
aria-label={`${t.severity === "error" ? "Error" : "Notification"}: ${t.title}`}
tabIndex={0}
onPointerEnter={() => pauseTimer(t.id, "hover")}
onPointerLeave={() => resumeTimer(t.id, "hover")}
onFocus={() => pauseTimer(t.id, "focus")}
onBlur={() => resumeTimer(t.id, "focus")}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
dismiss(t.id);
}
}}
className="overflow-hidden rounded-sm border border-border bg-background will-change-transform focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<CardShell toast={t} onDismiss={() => dismiss(t.id)} />
</div>
))}
{evicting.map((t) => (
<div
key={`evict-${t.id}`}
ref={(el) => attachEvicting(t, el)}
aria-hidden="true"
className="pointer-events-none overflow-hidden rounded-sm border border-border bg-background will-change-transform"
>
<CardShell toast={t} onDismiss={() => {}} />
</div>
))}
</div>
{history.length > 0 ? (
<div className="mt-2">
<button
type="button"
data-ic-history-toggle
aria-expanded={historyOpen}
aria-controls={historyPanelId}
onClick={() => setHistoryOpen((v) => !v)}
className="cursor-pointer rounded-sm border border-border px-2 py-1 font-mono text-[11px] tracking-wide text-muted transition-colors duration-150 hover:border-foreground/20 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
History ({history.length})
</button>
{historyOpen ? (
<ul
id={historyPanelId}
className="mt-1.5 flex flex-col gap-1 rounded-sm border border-border p-2"
>
{history.map((h) => (
<li
key={`${h.id}-${h.at}`}
data-ic-history-item
className="flex items-baseline justify-between gap-3 font-mono text-[11px]"
>
<span className="truncate text-foreground">{h.title}</span>
<span className="shrink-0 text-muted">{h.reason}</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
{/* single shared announcer: politeness swaps between info and error by
remounting on `seq`, since a live region's live-ness is typically
fixed at first AT encounter — physics-only reflow never touches
this, so an existing toast is never re-announced */}
<div key={live.seq} role="status" aria-live={live.level} aria-atomic="true" className="sr-only">
{live.text}
</div>
</div>
);
});
A toast/notification queue with a hard visible cap where the eviction itself needs to read as caused, not just as a card disappearing: momentum transfer is the entire identity here, an accelerating arrival strikes the stack and a staggered 2-3px shunt visibly carries that impact down to whichever toast gets ejected. Reach for toast-gravity-stack instead when the register should be a jostling gravity pile with drag-to-dismiss and mass-by-severity sorting, or status-glyph-cadence when the job is a single inline status glyph rather than a queue of dismissible cards.
A toast stack, capped at `maxVisible` (default 4), choreographed as a Newton's cradle rather than a generic slide/scale stack. RENDERING: plain DOM cards in a vertical flex column (severity icon, truncated title, optional mono message, dismiss button), newest on top; every transform is written directly via the Web Animations API on refs, not React state, so no canvas and no per-frame re-render. MOTION: a pushed toast enters at the top on an accelerating ease-in curve (cubic-bezier(.55,.06,.68,.19), 200ms, translateY -26px to 0 plus a 0.97-to-1 scale) reading as a released cradle ball picking up speed as it swings in and 'strikes' the queue. The instant it lands, every OTHER currently-visible toast (every toast except the new arrival) plays a 60ms shunt: translateY 0 to 3px and back, staggered 35ms deeper per row via the animation's own delay — a fast domino of tiny nudges that visibly propagates from the impact point to the far end, letting a glance at the stagger read the stack's depth without counting cards. Under capacity that impulse simply dissipates: the deepest toast shunts and returns exactly like every other row. At capacity, the same event instead ejects the oldest (bottommost) toast: it is pulled out of the visible list into a brief inert overlay and, at the exact stagger slot it would have shunted at, plays a 340ms departure on the ease-out curve cubic-bezier(0.16,1,0.3,1) — translateY 0 to 10px (the 'arc') with a 2deg rotation at the 35% mark, continuing to translateY 46px, rotate 9deg, opacity 0 — matching the inherited momentum with a decelerating exit rather than an abrupt cut. Manual dismiss and auto-expiry both play a plain 150ms opacity fade (no shunt, no eject arc) before the toast leaves the array, since those departures were never caused by an incoming impulse. AUTO-DISMISS: duration prop, default 5000ms per toast (0 disables); a hover OR a keyboard focus on the card pauses its own timer, and the timer only resumes once both are clear, so a screen-reader user tabbed onto a toast is never raced by the clock. ACCESSIBILITY: a single shared aria-live announcer (visually hidden) is the only thing that speaks — remounted (via a React key on an internal sequence number) each time so a screen reader re-registers its live-ness, aria-live='assertive' for error toasts and 'polite' for info, announcing each toast's title and message exactly once on arrival; the physics-only reflow of existing toasts (shunt, eject, reposition) never touches the announcer, so nothing already announced gets re-announced. Each toast card itself is role=alert (error) or role=status (info), keyboard-focusable, with an aria-label naming its severity and title; Escape while a toast has focus dismisses that toast. A global F6 keydown sends focus straight into the region — to the newest toast if one is visible, otherwise the region landmark itself — a pane-jump convention for reaching the stack without tabbing through the whole page. Every evicted, expired, or manually dismissed toast is appended to a capped (12-entry) history list surfaced behind a 'History (n)' disclosure button (aria-expanded/aria-controls) below the stack, so an ejected toast — the whole point of the eviction policy — stays reachable and readable, tagged with why it left ('evicted' / 'expired' / 'dismissed'). REDUCED MOTION: every custom animation call above is swapped for a plain 150-200ms opacity fade in the same Web Animations calls (checked once via a matchMedia('(prefers-reduced-motion: reduce)') listener) — no shunt, no eject arc, no translateY at all — while the queue, capacity, eviction, timer, and history logic all run identically, so the cap and the eviction reason are still fully legible, just without the choreography. Colors are exclusively --background/--foreground/--muted/--border/--accent (--accent only as the focus ring); severity is distinguished by icon shape and title weight, never by hue. Imperative handle exposes push/dismiss/clear; props: maxVisible, duration, initial (toasts seeded at mount), className, aria-label. Every timer, animation, and the window keydown listener is torn down on unmount.