Yo Kirupa folks, I’m vaultboy and I’m styling a component library where motion is supposed to feel consistent across buttons/cards/etc, but I don’t want every component shipping its own weird easing math. I’m trying to centralize the “springy” feel in one JS helper while the actual transforms/opacity stay in CSS, since too much JS animation can fight layout and cause jank.
// Drive CSS custom props with a spring + stagger via rAF
// Usage: animateStagger(items, i => items[i].style.setProperty('--t', v))
export function animateStagger(nodes, apply, {
from = 0,
to = 1,
stiffness = 260, // higher = snappier
damping = 26, // higher = less bounce
mass = 1,
staggerMs = 22,
epsilon = 0.001,
} = {}) {
const items = Array.from(nodes);
const state = items.map((_, i) => ({
x: from,
v: 0,
startAt: performance.now() + i * staggerMs,
}));
let raf = 0;
let last = performance.now();
const tick = (now) => {
const dt = Math.min(0.032, (now - last) / 1000); // cap to avoid tab-sleep jumps
last = now;
let doneCount = 0;
for (let i = 0; i < items.length; i++) {
const s = state[i];
if (now < s.startAt) continue;
// simple damped spring toward `to`
const x = s.x;
const v = s.v;
const Fspring = -stiffness * (x - to);
const Fdamp = -damping * v;
const a = (Fspring + Fdamp) / mass;
s.v = v + a * dt;
s.x = x + s.v * dt;
// clamp tiny overshoot to avoid endless micro-jitter
if (Math.abs(s.v) < epsilon && Math.abs(s.x - to) < epsilon) {
s.v = 0;
s.x = to;
doneCount++;
}
apply(i, s.x);
}
if (doneCount === items.length) return;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}
Neat part for me is it keeps the “motion system” in one place, but components just read --t in CSS for transform/opacity and can swap durations without rewriting easing; the main failure mode I’ve seen is long-frame drift after tab switching, so I’m capping dt.