A tiny springy stagger helper that plays nice with CSS variables

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.

1 Like