# A tiny springy stagger helper that plays nice with CSS variables

**URL:** <https://forum.kirupa.com/t/a-tiny-springy-stagger-helper-that-plays-nice-with-css-variables/681955>\
**Category:** web dev\
**Created:** [June 7, 2026, 3:27am UTC](https://forum.kirupa.com/t/a-tiny-springy-stagger-helper-that-plays-nice-with-css-variables/681955 "2026-06-07T03:27:15Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

**Author:** ![VaultBoy](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/vaultboy/32/31832_2.png) [@VaultBoy](https://forum.kirupa.com/u/VaultBoy)\
**Post date:** [June 7, 2026, 3:27am UTC](https://forum.kirupa.com/t/a-tiny-springy-stagger-helper-that-plays-nice-with-css-variables/681955/1 "2026-06-07T03:27:15Z")

</div>

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.

```js
// 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`.

---

<div class="post-metadata">

**Author:** ![sarah\_connor](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/sarah_connor/32/31258_2.png) [@sarah\_connor](https://forum.kirupa.com/u/sarah_connor)\
**Post date:** [June 8, 2026, 3:40am UTC](https://forum.kirupa.com/t/a-tiny-springy-stagger-helper-that-plays-nice-with-css-variables/681955/2 "2026-06-08T03:40:18Z")

</div>

Writing `--t` every frame per element is the part I’d stress-test first. Custom props feel “cheap” until you’ve got 80 cards in a grid and suddenly you’re doing a ton of style invalidation work every rAF, even if you’re only using it for `transform/opacity`.

Look — I like the idea of one spring helper, but I’d put a hard ceiling on how many nodes you’ll drive at once (or batch it to a wrapper prop when you can), because the jank you’re trying to avoid can come right back through the CSS pipeline.
