Toast stack with real gravity — notifications thud into a jostling heap, errors sink under their own mass, and the pile resettles when one is dismissed.
npx shadcn add https://design.helpmarq.com /r/toast-gravity-stack.jsonregistry/core/toast-gravity-stack/component.tsx"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
// ---------------------------------------------------------------------------
// SedimentStack — toasts fall under real gravity and pile into a jostling
// heap at the base of a docked surface. Severity sets mass (errors thud in
// heavy and migrate down, info lands light on top); dismissing mid-pile
// removes the collision body and the sediment above resettles. DOM cards,
// transforms written per-frame by a rigid-body-lite 2D sim — refs only on
// the hot path, and the whole sim sleeps once the pile settles.
// ---------------------------------------------------------------------------
export type SedimentSeverity = "error" | "warning" | "info";
export interface SedimentToastInput {
severity?: SedimentSeverity;
title: string;
message?: string;
/** ms before auto-dismiss; 0 disables; defaults to the stack `duration` */
duration?: number;
}
export interface SedimentStackHandle {
/** drop a toast into the pile, returns its id */
push: (toast: SedimentToastInput) => string;
dismiss: (id: string) => void;
clear: () => void;
}
type ToastRec = {
id: string;
severity: SedimentSeverity;
title: string;
message?: string;
duration: number;
};
type Body = {
id: string;
el: HTMLDivElement;
x: number;
y: number;
a: number;
vx: number;
vy: number;
om: number;
hw: number;
hh: number;
/** collision circle radius (half card height → rounded-box proxy) */
r: number;
/** local x offsets of the collision circles along the card's midline */
lxs: number[];
bound: number;
invMass: number;
invI: number;
baseInvMass: number;
baseInvI: number;
born: number;
dead: boolean;
dragging: boolean;
dragPX: number;
dragX0: number;
dragLastX: number;
dragLastT: number;
dragVX: number;
spring: boolean;
anchorX: number;
hovered: boolean;
/** frozen into static geometry (invMass 0) after 12 calm frames */
asleep: boolean;
calm: number;
/** seconds awake since spawn/wake — drives the forced-settle rule */
age: number;
};
type TimerEntry = {
handle: number | null;
deadline: number;
remaining: number;
};
// tray composition only (not a sim constant): keeps the physics floor a few
// px above the frame's visible bottom edge so a rotated card's circle-proxy
// corner overhang never visually crosses the clipped edge
const FLOOR_INSET = 6;
// severity → left-rail color only; the card itself stays on neutral tokens
const RAIL: Record<SedimentSeverity, string> = {
error: "var(--error, #ea001d)",
warning: "var(--warning, #f5a623)",
info: "var(--success, #47a447)",
};
const ICONS: Record<SedimentSeverity, 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>
),
warning: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M12 3 2.7 19a1.5 1.5 0 0 0 1.3 2.3h16a1.5 1.5 0 0 0 1.3-2.3L12 3Z" />
<path d="M12 10v4" />
<path d="M12 17.4h.01" />
</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 CardInner({
toast,
onDismiss,
}: {
toast: ToastRec;
onDismiss: () => void;
}) {
return (
<div className="flex items-start gap-2.5 py-2 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] font-medium leading-tight text-foreground">
{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>
);
}
// reduced-motion fallback card: classic list entry, 150ms opacity fade in/out
function ReducedCard({
toast,
exiting,
onDismiss,
onEnter,
onLeave,
}: {
toast: ToastRec;
exiting: boolean;
onDismiss: () => void;
onEnter: () => void;
onLeave: () => void;
}) {
const [shown, setShown] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(id);
}, []);
return (
<div
role={toast.severity === "error" ? "alert" : "status"}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
className={`w-full rounded-sm border border-border bg-surface transition-opacity duration-150 hover:border-foreground/20 ${
shown && !exiting ? "opacity-100" : "opacity-0"
}`}
style={{ borderLeftWidth: 3, borderLeftColor: RAIL[toast.severity] }}
>
<CardInner toast={toast} onDismiss={onDismiss} />
</div>
);
}
export const SedimentStack = forwardRef<
SedimentStackHandle,
{
/** ms before a toast auto-dismisses (paused on hover); 0 disables */
duration?: number;
/** px/s^2 downward */
gravity?: number;
/** bounce energy kept on impact, 0..1 */
restitution?: number;
/** toasts present at mount */
initial?: SedimentToastInput[];
className?: string;
"aria-label"?: string;
}
>(function SedimentStack(
{
duration = 6000,
gravity = 1800,
restitution = 0.15,
initial,
className = "h-96",
"aria-label": ariaLabel = "Notifications",
},
ref
) {
const containerRef = useRef<HTMLDivElement>(null);
const bodiesRef = useRef<Map<string, Body>>(new Map());
const timersRef = useRef<Map<string, TimerEntry>>(new Map());
const removalsRef = useRef<Set<number>>(new Set());
const dismissedRef = useRef<Set<string>>(new Set());
const wakeRef = useRef<() => void>(() => {});
const kickRef = useRef<() => void>(() => {});
const shadowRef = useRef("0 10px 24px -10px rgba(0,0,0,0.28)");
const restShadowRef = useRef("0 3px 10px -6px rgba(0,0,0,0.3)");
const seqRef = useRef(0);
const bornRef = useRef(0);
const spawnSlotRef = useRef(0);
const [toasts, setToasts] = useState<ToastRec[]>(() =>
(initial ?? []).map((t, i) => ({
id: `sed-init-${i}`,
severity: t.severity ?? "info",
title: t.title,
message: t.message,
duration: t.duration ?? duration,
}))
);
const [exiting, setExiting] = useState<ReadonlySet<string>>(new Set());
const [reduced, setReduced] = useState(false);
const toastsRef = useRef(toasts);
toastsRef.current = toasts;
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
// hover-lift + resting shadows are tinted from a fixed dark "ink" (always
// rgba(0,0,0,...)), not a theme token — a shadow needs to stay dark in
// both themes, unlike --foreground which flips to near-white in dark mode
// and would otherwise paint a light halo around every card. Resting shadow
// is a subtle version applied to every settled card so the pile reads as
// sitting under real weight, not flat cutouts. shadowRef/restShadowRef are
// already seeded with these constants at declaration, so no derive effect
// is needed here.
// oldest layers fade toward 0.55 as newer sediment lands on top
const recomputeDepth = useCallback(() => {
const alive: Body[] = [];
for (const b of bodiesRef.current.values()) if (!b.dead) alive.push(b);
alive.sort((a, b) => a.born - b.born);
const n = alive.length;
for (let i = 0; i < n; i++) {
alive[i].el.style.opacity = Math.max(0.55, 1 - (n - 1 - i) * 0.06).toFixed(2);
}
}, []);
const pauseTimer = useCallback((id: string) => {
const entry = timersRef.current.get(id);
if (!entry || entry.handle === null) return;
clearTimeout(entry.handle);
entry.handle = null;
entry.remaining = Math.max(0, entry.deadline - Date.now());
}, []);
const dismiss = useCallback(
(id: string) => {
const entry = timersRef.current.get(id);
if (entry) {
if (entry.handle !== null) clearTimeout(entry.handle);
timersRef.current.delete(id);
}
if (dismissedRef.current.has(id)) return;
dismissedRef.current.add(id);
const body = bodiesRef.current.get(id);
if (body && !body.dead) {
// collision body gone immediately — the pile above resettles while
// the card fades out over 150ms
body.dead = true;
body.dragging = false;
body.el.style.opacity = "0";
}
setExiting((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
const handle = window.setTimeout(() => {
removalsRef.current.delete(handle);
dismissedRef.current.delete(id);
bodiesRef.current.delete(id);
setToasts((prev) => prev.filter((t) => t.id !== id));
setExiting((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
recomputeDepth();
wakeRef.current();
}, 180);
removalsRef.current.add(handle);
wakeRef.current();
},
[recomputeDepth]
);
const resumeTimer = useCallback(
(id: string) => {
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), ms);
},
[dismiss]
);
// arm auto-dismiss timers for freshly pushed toasts
useEffect(() => {
const timers = timersRef.current;
for (const t of toasts) {
if (t.duration <= 0 || timers.has(t.id) || dismissedRef.current.has(t.id))
continue;
timers.set(t.id, {
handle: window.setTimeout(() => dismiss(t.id), t.duration),
deadline: Date.now() + t.duration,
remaining: t.duration,
});
}
}, [toasts, dismiss]);
// teardown: every pending timer and removal timeout dies with the component
useEffect(() => {
const timers = timersRef.current;
const removals = removalsRef.current;
return () => {
for (const entry of timers.values()) {
if (entry.handle !== null) clearTimeout(entry.handle);
}
timers.clear();
for (const handle of removals) clearTimeout(handle);
removals.clear();
};
}, []);
const push = useCallback(
(input: SedimentToastInput) => {
const id = `sed-${++seqRef.current}`;
setToasts((prev) => [
...prev,
{
id,
severity: input.severity ?? "info",
title: input.title,
message: input.message,
duration: input.duration ?? duration,
},
]);
return id;
},
[duration]
);
const clear = useCallback(() => {
for (const t of toastsRef.current) dismiss(t.id);
}, [dismiss]);
useImperativeHandle(ref, () => ({ push, dismiss, clear }), [push, dismiss, clear]);
// spawn a rigid body the moment a card element mounts: entry x round-robins
// across a handful of tray-width slots (jittered) so the pile heaps instead
// of sliding in from one edge, vx jitter ±40 px/s, rotation jitter ±4°,
// mass by severity (3/2/1)
const attach = useCallback(
(t: ToastRec, el: HTMLDivElement | null) => {
const bodies = bodiesRef.current;
const existing = bodies.get(t.id);
if (!el) {
// re-render ref churn detaches then reattaches the same node; only a
// dismissed body is truly gone
if (existing && existing.dead) bodies.delete(t.id);
return;
}
if (existing) {
// Only a genuinely new node (real remount) should wake the sim —
// the churn case above reattaches this exact same element on every
// unrelated re-render of the list, and waking on every one of those
// resets the settle deadline continuously, so the sim never parks.
const isNewNode = existing.el !== el;
existing.el = el;
if (isNewNode) wakeRef.current();
return;
}
const root = containerRef.current;
const cw = root ? root.clientWidth : 0;
const w = el.offsetWidth || 288;
const h = el.offsetHeight || 60;
const hw = w / 2;
const hh = h / 2;
const r = hh; // rounded-box proxy: circles of half-height radius
const span = Math.max(0, hw - r);
const count = Math.max(2, Math.ceil((span * 2) / (r * 1.2)) + 1);
const lxs: number[] = [];
for (let i = 0; i < count; i++)
lxs.push(-span + (span * 2 * i) / (count - 1));
const mass = t.severity === "error" ? 3 : t.severity === "warning" ? 2 : 1;
const inertia = (mass * (w * w + h * h)) / 12;
// distribute drop x across up to 4 tray slots (round-robin + jitter)
// instead of always the far-right edge, so the pile heaps rather than
// sliding in flat from one side
const SLOT_COUNT = 4;
const slotLeft = hw + 8;
const slotRight = cw - hw - 8;
const slotSpan = slotRight - slotLeft;
let x: number;
if (cw > w + 40 && slotSpan > 0) {
const n = Math.min(SLOT_COUNT, Math.max(1, Math.floor(slotSpan / 40) + 1));
const slot = spawnSlotRef.current % n;
spawnSlotRef.current++;
const frac = n === 1 ? 0.5 : slot / (n - 1);
const jitter = (Math.random() - 0.5) * Math.min(28, slotSpan / n);
x = Math.min(slotRight, Math.max(slotLeft, slotLeft + frac * slotSpan + jitter));
} else {
x = Math.max(hw + 2, cw - hw - 2);
}
const body: Body = {
id: t.id,
el,
x,
y: -hh - 6,
a: (Math.random() - 0.5) * ((8 * Math.PI) / 180),
vx: (Math.random() - 0.5) * 40,
vy: 0,
om: 0,
hw,
hh,
r,
lxs,
bound: Math.hypot(hw, hh) + 2,
invMass: 1 / mass,
invI: 1 / inertia,
baseInvMass: 1 / mass,
baseInvI: 1 / inertia,
born: ++bornRef.current,
dead: false,
dragging: false,
dragPX: 0,
dragX0: 0,
dragLastX: 0,
dragLastT: 0,
dragVX: 0,
spring: false,
anchorX: 0,
hovered: false,
asleep: false,
calm: 0,
age: 0,
};
el.style.transform = `translate3d(${(body.x - hw).toFixed(2)}px, ${(body.y - hh).toFixed(2)}px, 0) rotate(${body.a.toFixed(4)}rad)`;
el.style.boxShadow = restShadowRef.current;
bodies.set(t.id, body);
recomputeDepth();
wakeRef.current();
},
[recomputeDepth]
);
// -------------------------------------------------------------------------
// the sim — fixed 120Hz substeps, impulse contacts, sleeps when settled
// -------------------------------------------------------------------------
useEffect(() => {
if (reduced) return;
const root = containerRef.current;
if (!root) return;
let W = root.clientWidth;
let H = root.clientHeight;
let raf = 0;
let last = 0;
let acc = 0;
// seconds of live sim since the last real wake — the global settle deadline
let active = 0;
const FIXED = 1 / 120;
// ponytail: superphysical stiction (cone past 45°) — at 0.85 any contact
// steeper than ~40° slides under gravity and the pile reorganizes for
// minutes, never reaching the sleep epsilon; cards in a tray should lock
const MU = 1.2;
const SPRING_K = 180; // swipe spring-back stiffness (s^-2)
const SPRING_C = 2 * 0.9 * Math.sqrt(SPRING_K); // zeta = 0.9
const pa: number[] = new Array(64).fill(0);
const pb: number[] = new Array(64).fill(0);
const worldPts = (b: Body, out: number[]) => {
const cos = Math.cos(b.a);
const sin = Math.sin(b.a);
for (let i = 0; i < b.lxs.length; i++) {
const lx = b.lxs[i];
out[i * 2] = b.x + lx * cos;
out[i * 2 + 1] = b.y + lx * sin;
}
};
// impulse + Baumgarte projection at one contact point; B null = static
const applyContact = (
A: Body,
B: Body | null,
px: number,
py: number,
nx: number,
ny: number,
pen: number
) => {
const rax = px - A.x;
const ray = py - A.y;
let rbx = 0;
let rby = 0;
let invMB = 0;
let invIB = 0;
let vbx = 0;
let vby = 0;
let wb = 0;
if (B) {
rbx = px - B.x;
rby = py - B.y;
invMB = B.invMass;
invIB = B.invI;
vbx = B.vx;
vby = B.vy;
wb = B.om;
}
const invSum = A.invMass + invMB;
if (invSum > 0 && pen > 0.5) {
const corr = ((pen - 0.5) / invSum) * 0.3;
A.x -= corr * A.invMass * nx;
A.y -= corr * A.invMass * ny;
if (B) {
B.x += corr * B.invMass * nx;
B.y += corr * B.invMass * ny;
}
}
const rvx = vbx - wb * rby - (A.vx - A.om * ray);
const rvy = vby + wb * rbx - (A.vy + A.om * rax);
const vn = rvx * nx + rvy * ny;
if (vn >= 0) return;
const raXn = rax * ny - ray * nx;
const rbXn = rbx * ny - rby * nx;
const kn = invSum + raXn * raXn * A.invI + rbXn * rbXn * invIB;
if (kn <= 0) return;
const e = vn < -80 ? restitution : 0;
const j = (-(1 + e) * vn) / kn;
A.vx -= j * nx * A.invMass;
A.vy -= j * ny * A.invMass;
A.om -= j * raXn * A.invI;
if (B) {
B.vx += j * nx * B.invMass;
B.vy += j * ny * B.invMass;
B.om += j * rbXn * B.invI;
}
let tx = rvx - vn * nx;
let ty = rvy - vn * ny;
const tl = Math.hypot(tx, ty);
if (tl > 1e-6) {
tx /= tl;
ty /= tl;
const raXt = rax * ty - ray * tx;
const rbXt = rbx * ty - rby * tx;
const kt = invSum + raXt * raXt * A.invI + rbXt * rbXt * invIB;
if (kt > 0) {
let jt = -(rvx * tx + rvy * ty) / kt;
const maxF = MU * j;
if (jt > maxF) jt = maxF;
else if (jt < -maxF) jt = -maxF;
A.vx -= jt * tx * A.invMass;
A.vy -= jt * ty * A.invMass;
A.om -= jt * raXt * A.invI;
if (B) {
B.vx += jt * tx * B.invMass;
B.vy += jt * ty * B.invMass;
B.om += jt * rbXt * B.invI;
}
}
}
};
const step = (dt: number, bodies: Body[]) => {
for (const b of bodies) {
if (b.invMass === 0) continue; // dragged bodies are kinematic
b.vy += gravity * dt;
b.age += dt;
if (b.spring) {
b.vx += (-SPRING_K * (b.x - b.anchorX) - SPRING_C * b.vx) * dt;
if (Math.abs(b.x - b.anchorX) < 0.5 && Math.abs(b.vx) < 3)
b.spring = false;
}
}
for (let iter = 0; iter < 3; iter++) {
for (let i = 0; i < bodies.length; i++) {
const A = bodies[i];
worldPts(A, pa);
for (let ci = 0; ci < A.lxs.length; ci++) {
const cxw = pa[ci * 2];
const cyw = pa[ci * 2 + 1];
const overFloor = cyw + A.r - H;
if (overFloor > 0) applyContact(A, null, cxw, H, 0, 1, overFloor);
const overLeft = A.r - cxw;
if (overLeft > 0) applyContact(A, null, 0, cyw, -1, 0, overLeft);
const overRight = cxw + A.r - W;
if (overRight > 0) applyContact(A, null, W, cyw, 1, 0, overRight);
}
for (let jn = i + 1; jn < bodies.length; jn++) {
const B = bodies[jn];
if (A.asleep && B.asleep) continue; // both frozen — nothing to solve
const dx = B.x - A.x;
const dy = B.y - A.y;
const rr = A.bound + B.bound;
if (dx * dx + dy * dy > rr * rr) continue;
// an energetic awake body crashing into frozen geometry wakes it
if (A.asleep !== B.asleep) {
const mover = A.asleep ? B : A;
const frozen = A.asleep ? A : B;
if (!frozen.dragging && Math.hypot(mover.vx, mover.vy) > 60) {
frozen.asleep = false;
frozen.calm = 0;
frozen.age = 0;
frozen.invMass = frozen.baseInvMass;
frozen.invI = frozen.baseInvI;
}
}
worldPts(B, pb);
const rsum = A.r + B.r;
for (let pi = 0; pi < A.lxs.length; pi++) {
for (let qi = 0; qi < B.lxs.length; qi++) {
const ddx = pb[qi * 2] - pa[pi * 2];
const ddy = pb[qi * 2 + 1] - pa[pi * 2 + 1];
const d2 = ddx * ddx + ddy * ddy;
if (d2 >= rsum * rsum) continue;
const d = Math.sqrt(d2);
// coincident centers: fall back to a vertical normal —
// never divide by a zero-length vector
let nx = 0;
let ny = -1;
if (d > 1e-6) {
nx = ddx / d;
ny = ddy / d;
}
const pen = rsum - d;
applyContact(
A,
B,
pa[pi * 2] + nx * (A.r - pen / 2),
pa[pi * 2 + 1] + ny * (A.r - pen / 2),
nx,
ny,
pen
);
}
}
}
}
}
for (const b of bodies) {
if (b.invMass === 0) continue;
const sp = Math.hypot(b.vx, b.vy);
if (sp > 1400) {
const s = 1400 / sp;
b.vx *= s;
b.vy *= s;
}
b.x += b.vx * dt;
b.y += b.vy * dt;
b.a += b.om * dt;
// framerate-normalized angular damping (0.92 per 60Hz frame)
b.om *= Math.pow(0.92, dt * 60);
// chatter damping: a body wedged between frozen (infinite-mass)
// neighbors ping-pongs impulses at 10-20px/s forever — drain any
// sub-30px/s motion hard so it dips under the sleep epsilon
if (Math.abs(b.vx) < 30 && Math.abs(b.vy) < 30) {
b.vx *= 0.85;
b.vy *= 0.85;
b.om *= 0.85;
}
}
};
const loop = (now: number) => {
raf = 0;
const dtF = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
last = now;
const bodies: Body[] = [];
for (const b of bodiesRef.current.values()) if (!b.dead) bodies.push(b);
// zero-size guard: bodies wait until the container has real bounds
if (W > 4 && H > 4 && bodies.length > 0) {
active += dtF;
acc = Math.min(acc + dtF, FIXED * 6);
while (acc >= FIXED) {
step(FIXED, bodies);
acc -= FIXED;
}
for (const b of bodies) {
const tx = b.x - b.hw;
const ty = b.y - b.hh + (b.hovered ? -2 : 0);
b.el.style.transform = `translate3d(${tx.toFixed(2)}px, ${ty.toFixed(2)}px, 0) rotate(${b.a.toFixed(4)}rad)`;
}
}
// per-body sleep: a body calm (|v| < 3 px/s, |ω| < 0.02 rad/s, no
// spring/drag) for 12 consecutive frames freezes into static geometry
// (invMass 0) so the pile locks progressively instead of reorganizing
// forever. Loop parks when everything is frozen; spawn/dismiss/resize
// full-wake, an energetic contact wakes one frozen body.
// global settle deadline: 4s after the last real wake, everything
// freezes unconditionally — no per-body wake/reset path can evade it.
// The pile must reach exact rest deterministically; the sim is for the
// landing choreography, not for eternal micro-motion.
const deadline = active > 4;
let allAsleep = true;
for (const b of bodies) {
if (b.dragging) {
allAsleep = false;
continue;
}
if (b.asleep) continue;
// forced settle: a UI pile must reach rest deterministically and a
// free impulse sim never quite does (wedged bodies chatter between
// frozen infinite-mass neighbors). Any body awake > 2.5s and moving
// under 40px/s parks on the spot — imperceptible at that speed.
// ponytail: fiat rest; revisit only if piles photograph mid-topple
// hard stop at 4s: a card can toboggan down a frozen neighbor's
// scalloped (circle-proxy) edge at 40-60px/s indefinitely — nothing
// in a toast pile legitimately animates that long, and every
// spawn/drag/dismiss resets the clock
if (
deadline ||
b.age > 4 ||
(!b.spring &&
b.age > 2.5 &&
Math.hypot(b.vx, b.vy) < 40 &&
Math.abs(b.om) < 0.3)
) {
b.spring = false;
b.asleep = true;
b.vx = 0;
b.vy = 0;
b.om = 0;
b.invMass = 0;
b.invI = 0;
continue;
}
// 8px/s: strict 3 left grinding bodies oscillating just above it —
// sub-8px/s residuals zero out invisibly on freeze, and free fall
// crosses this band in <1 substep so nothing parks mid-air
if (!b.spring && Math.hypot(b.vx, b.vy) < 8 && Math.abs(b.om) < 0.05) {
b.calm++;
if (b.calm >= 12) {
b.asleep = true;
b.vx = 0;
b.vy = 0;
b.om = 0;
b.invMass = 0;
b.invI = 0;
continue;
}
} else {
b.calm = 0;
}
allAsleep = false;
}
if (allAsleep) return; // parked — kick()/wake() restart the loop
raf = requestAnimationFrame(loop);
};
// repaint/restart only — sleeping bodies stay frozen (hover lift, drag)
const kick = () => {
if (raf === 0) {
last = 0;
active = 0; // restarting from park = a fresh activity window
raf = requestAnimationFrame(loop);
}
};
// full physics wake — support or geometry changed (spawn/dismiss/resize)
const wake = () => {
// eslint-disable-next-line no-console
console.log("[sediment-debug] wake() called");
for (const b of bodiesRef.current.values()) {
if (b.dead || b.dragging) continue;
if (b.asleep) {
b.asleep = false;
b.invMass = b.baseInvMass;
b.invI = b.baseInvI;
}
b.calm = 0;
b.age = 0;
}
active = 0;
kick();
};
wakeRef.current = wake;
kickRef.current = kick;
const ro = new ResizeObserver(() => {
const rect = root.getBoundingClientRect();
W = rect.width;
H = rect.height;
wake();
});
ro.observe(root);
wake();
return () => {
cancelAnimationFrame(raf);
raf = -1; // wake() after teardown must not restart the loop
ro.disconnect();
wakeRef.current = () => {};
kickRef.current = () => {};
};
}, [reduced, gravity, restitution]);
// -------------------------------------------------------------------------
// interaction — hover lift + timer pause, swipe-right dismissal
// -------------------------------------------------------------------------
const hoverIn = useCallback(
(id: string) => {
pauseTimer(id);
const b = bodiesRef.current.get(id);
if (b && !b.dead) {
b.hovered = true;
b.el.style.boxShadow = shadowRef.current;
kickRef.current();
}
},
[pauseTimer]
);
const hoverOut = useCallback(
(id: string) => {
resumeTimer(id);
const b = bodiesRef.current.get(id);
if (b && !b.dead) {
b.hovered = false;
b.el.style.boxShadow = restShadowRef.current;
kickRef.current();
}
},
[resumeTimer]
);
const beginDrag = useCallback(
(id: string, e: ReactPointerEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest("button")) return;
const b = bodiesRef.current.get(id);
if (!b || b.dead) return;
e.currentTarget.setPointerCapture(e.pointerId);
b.dragging = true;
b.asleep = false;
b.calm = 0;
b.spring = false;
b.dragPX = e.clientX;
b.dragX0 = b.x;
b.dragLastX = e.clientX;
b.dragLastT = performance.now();
b.dragVX = 0;
b.invMass = 0; // kinematic while held — the pile leans on it
b.invI = 0;
b.vx = 0;
b.vy = 0;
b.om = 0;
kickRef.current();
},
[]
);
const moveDrag = useCallback((id: string, e: ReactPointerEvent<HTMLDivElement>) => {
const b = bodiesRef.current.get(id);
if (!b || !b.dragging || b.dead) return;
b.x = b.dragX0 + Math.max(-24, e.clientX - b.dragPX);
const now = performance.now();
const dt = (now - b.dragLastT) / 1000;
if (dt > 0.001) b.dragVX = ((e.clientX - b.dragLastX) / dt) * 0.6;
b.dragLastX = e.clientX;
b.dragLastT = now;
kickRef.current();
}, []);
const endDrag = useCallback(
(id: string, cancelled: boolean) => {
const b = bodiesRef.current.get(id);
if (!b || !b.dragging) return;
b.dragging = false;
b.invMass = b.baseInvMass;
b.invI = b.baseInvI;
const dx = b.x - b.dragX0;
if (!cancelled && dx > 80) {
dismiss(id);
} else {
// under threshold: spring back to the grab origin (k=180, ζ=0.9)
b.spring = true;
b.anchorX = b.dragX0;
b.vx = b.dragVX * 0.3;
}
// full wake: the spring-back needs a fresh activity window, and the
// pile may have to reflow around wherever the card was released
wakeRef.current();
},
[dismiss]
);
if (reduced) {
return (
<div
ref={containerRef}
role="region"
aria-label={ariaLabel}
className={`relative overflow-y-auto ${className}`}
>
<div className="flex flex-col gap-2 p-3">
{[...toasts].reverse().map((t) => (
<ReducedCard
key={t.id}
toast={t}
exiting={exiting.has(t.id)}
onDismiss={() => dismiss(t.id)}
onEnter={() => pauseTimer(t.id)}
onLeave={() => resumeTimer(t.id)}
/>
))}
</div>
</div>
);
}
return (
<div
role="region"
aria-label={ariaLabel}
className={`relative overflow-hidden ${className}`}
>
{/* physics frame is FLOOR_INSET px shorter than the visible box — the
sim's floor sits above the clip line instead of exactly on it, so
resting cards never read as clipped */}
<div ref={containerRef} className="absolute inset-x-0 top-0" style={{ height: `calc(100% - ${FLOOR_INSET}px)` }}>
{toasts.map((t) => (
<div
key={t.id}
ref={(el) => attach(t, el)}
role={t.severity === "error" ? "alert" : "status"}
onPointerDown={(e) => beginDrag(t.id, e)}
onPointerMove={(e) => moveDrag(t.id, e)}
onPointerUp={() => endDrag(t.id, false)}
onPointerCancel={() => endDrag(t.id, true)}
onPointerEnter={() => hoverIn(t.id)}
onPointerLeave={() => hoverOut(t.id)}
className="absolute left-0 top-0 w-64 max-w-[calc(100%-16px)] cursor-grab touch-pan-y select-none rounded-sm border border-border bg-surface transition-opacity duration-150 will-change-transform hover:border-foreground/20"
style={{
borderLeftWidth: 3,
borderLeftColor: RAIL[t.severity],
// constant in props → React never rewrites it over the sim's
// per-frame writes; attach() replaces it before first paint
transform: "translate3d(-200%, -200%, 0)",
}}
>
<CardInner toast={t} onDismiss={() => dismiss(t.id)} />
</div>
))}
</div>
{/* soft vignette reads the inset floor gap as intentional depth, not a
rendering gap */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-background/60 to-transparent"
/>
</div>
);
});
A toast system where notifications fall under real gravity and pile into a jostling heap at the base of a docked surface. RENDERING: real DOM toast cards (severity icon, title, mono message, dismiss button, role=status/alert) whose translate/rotate transforms are written per-frame by a rigid-body-lite 2D sim — refs only on the hot path, no React state, no canvas. Each card's collision proxy is a rounded box: a row of circles of half-card-height radius along the card midline (corner circles plus midline fill), so corners round off and nothing tunnels through edge gaps; container floor and walls are static planes. MOTION: gravity 1800 px/s^2 at fixed 120Hz substeps; restitution 0.15 applied only above an 80 px/s impact threshold; tangential Coulomb friction mu 1.2 at every contact (raised from an earlier 0.85, which let cards slide under >40° contacts forever); angular damping 0.92/frame framerate-normalized via pow(0.92, dt*60); mass by severity error=3 / warning=2 / info=1 so heavier bodies displace lighter ones on impact and errors migrate toward the pile floor. Toasts spawn from the top, x round-robined across up to 4 jittered tray-width slots (vs. a single fixed edge) so the pile heaps rather than sliding in flat from one side, with vx jitter +/-40 px/s and rotation jitter +/-4 deg; each arrival's impact impulse briefly jostles the pile. The physics floor sits a few px above the tray's visible bottom edge (a shorter inner sim frame inside the clipped outer box) so a rotated card's corner overhang never reads as clipped. SLEEP: the whole sim parks when every body holds |v| < 8 px/s and |omega| < 0.05 rad/s for 12 consecutive frames; spawn, dismiss, hover, drag, and resize wake it. Auto-dismiss default 6s per toast, paused on hover; oldest layers fade to opacity 0.55 as newer sediment lands. Dismissing removes the collision body instantly (150ms opacity fade on the card) and the sediment above resettles under gravity. INTERACTION: hover lifts the card 2px and applies a shadow tinted from a fixed dark ink (rgba(0,0,0,...), not a theme token, so it never inverts to a light halo in dark mode); drag right past 80px dismisses, under-threshold release springs back with k=180, zeta=0.9 while the held card goes kinematic so the pile leans on it. Severity color appears only as a 3px left rail using var(--error)/var(--warning)/var(--success); everything else is neutral tokens (bg-surface, border-border, hover:border-foreground/20, accent focus ring on dismiss buttons). REDUCED MOTION: classic static vertical list, newest on top, instant add/remove with 150ms opacity fades, no physics. Imperative handle exposes push/dismiss/clear. ResizeObserver re-derives floor and walls (zero-size container guarded, coincident-center collision normals fall back to vertical); every rAF, observer, listener, and timer is torn down on unmount.