npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-scroll-rig-pro

v1.3.2

Published

Zero-GC scroll-driven transform rig. Spring-smoothed virtual scroll, pre-measured intersection bounds, keyframed matrix3d batched to the DOM. Built for 16ms frame budgets and long-running sessions.

Readme

@zakkster/lite-scroll-rig-pro

Zero-GC scroll-driven transform rig. A spring smooths stepped wheel/touch input into a single scroll value; pre-measured intersection bounds and a keyframe pool turn that value into batched matrix3d transforms. Built for 16ms frame budgets and sessions that run for hours.

npm version sponsor npm bundle size npm downloads npm total downloads Zero-GC TypeScript license

The capstone of the @zakkster motion stack: five primitives, one system. lite-spring (feel), lite-keyframe (tracks), lite-gesture (pinch-safe touch), lite-dom-binder (batched transforms), and lite-lerp (clamp / inverseLerp) are wired into a single spring-smoothed, allocation-free scroll rig -- the animation frame loop performs no heap allocation after setup, all per-frame math writes into pre-allocated typed arrays, and the transform batch is flushed in one pass.

The niche is narrow and deliberate: full-page overlays and kiosks, hour-long unattended sessions where a single dropped frame compounds, and scroll-synced WebGL where the eased scroll value has to feed a GPU uniform. If you want a fade-in on a marketing page, reach for CSS animation-timeline first (see below). If you want a virtual scroll decoupled from the document with carried-velocity spring feel and a provably-zero-alloc loop, that is what this is.

npm install @zakkster/lite-scroll-rig-pro
import { ScrollEngine, DOMScroller } from "@zakkster/lite-scroll-rig-pro";
import { KeyframePool } from "@zakkster/lite-keyframe";

// 4 keyframe tracks per element: [translateX, translateY, scale, rotateZ]
const cards = [...document.querySelectorAll(".card")];
const pool = new KeyframePool(cards.length * 4, 3);
cards.forEach((_, i) => {
  const row = i * 4;
  pool.addKey(row + 2, 0, 0.85);   // scale in ...
  pool.addKey(row + 2, 0.5, 1);
  pool.addKey(row + 2, 1, 0.85);   // ... and back out as it leaves
  pool.addKey(row + 3, 0, -6);     // rotateZ sweeps -6deg -> 6deg
  pool.addKey(row + 3, 1, 6);
});

const engine = new ScrollEngine(window);
engine.addRenderer(new DOMScroller(cards, pool));
engine.resize();
engine.start();

Synchronous frame loop, spring-eased motion, zero per-frame garbage.


Table of contents


Why this exists

Smooth-scroll libraries all do the same three things: intercept the native scroll, ease toward a target, and drive transforms from the eased value. The differences live in the frame loop, and most libraries allocate there -- a closure per element, an object per frame, a fresh array for the visible set. On a 60fps overlay with a strict bundle budget, that allocation is what eventually stalls a frame.

lite-scroll-rig-pro was built so the steady-state frame loop touches no heap:

  1. No allocation after setup. Bounds are measured once into a Float32Array; matrices are composed into a shared buffer; the DOM flush reuses that buffer. render(currentY) allocates nothing.
  2. One layout read pass. getBoundingClientRect is called only in measure() -- on load and on resize -- never inside the frame loop.
  3. Injectable everything. Input, spring, window, rAF, gesture factory, and ResizeObserver are all injectable, so the whole rig runs headlessly under node:test.

CSS scroll-driven animations (use them first)

Be honest about the competition. For a fade-in as a section enters the viewport, or a progress bar that fills as the page scrolls, the right answer in 2025 is CSS -- animation-timeline: view() and animation-timeline: scroll(). Those run off the main thread, allocate nothing in JS because there is no JS, degrade gracefully where unsupported, and need no library at all. If that is your whole requirement, close this tab and use the platform.

What CSS scroll-driven animations cannot do, and where this rig earns its place:

  • A virtual scroll decoupled from the document. CSS timelines are bound to real scroll positions of real scroll containers. This rig owns a targetY that is not the document's scroll -- it can run inside a fixed viewport, be driven by a gesture, or be set programmatically, with no scrollbar of its own.
  • Spring feel with carried velocity. A CSS timeline maps scroll offset to animation progress linearly (or through an easing function of progress, not of input speed). A spring carries velocity, so a fast flick overshoots and settles; a slow drag does not. That is a physical response to input, not a curve.
  • Feeding a value into WebGL. A CSS timeline drives CSS properties. It cannot hand a smoothed scalar to a shader uniform or a GPU instance buffer. This rig's currentY is a plain number you can route anywhere (see below).
  • A provably-zero-alloc, multi-hour loop with idle discipline. The frame loop is gated at 0 B/op and parks when the spring settles, waking on input -- built for kiosks and overlays that run untouched for hours.

Use CSS until one of those four lines is your requirement. Then use this.


Scroll-synced WebGL

Because currentY is just a smoothed number, any renderer with a render(currentY) method can consume it -- including one that drives a GPU. recipes/gl-sync.js (repo-only; never shipped) wires the rig to @zakkster/lite-gl's instanced point field with zero per-frame allocation. It rides currentY into the field data (per-point depth parallax on the POINT layout + createPointSink); the camera path (createPointHiSink + setCamera) is the alternative when the whole scene should translate as one rigid layer.

import { LAYOUT } from "@zakkster/lite-gl";

// createGLScrollRenderer({ field, sink, depth }) -> { render, resize, destroy }
export function createGLScrollRenderer({ field, sink, depth = 1, stride = LAYOUT.POINT }) {
  if (!sink || !sink.caps) throw new RangeError("sink with frozen caps required"); // fail closed
  if (!field || !field.data) throw new RangeError("a lite-gl Field is required");
  const data = field.data, count = field.count | 0;   // caps read ONCE, at construction
  const baseY = new Float32Array(count), factor = new Float32Array(count);
  for (let i = 0; i < count; i++) {                    // hoist base positions + parallax gain
    baseY[i] = data[i * stride + 1];
    factor[i] = depth * (1 - 0.5 * (i / (count || 1))); // strictly positive
  }
  return {
    render(currentY) {                                 // 0 B/op: writes into the field's own scratch
      for (let i = 0; i < count; i++) data[i * stride + 1] = baseY[i] + currentY * factor[i];
      field.touchAll();
      field.flush(sink);                               // uploads the dirty range AND draws
    },
    resize(w, h) { if (w > 0 && h > 0) sink.resize(w | 0, h | 0); },
    destroy() { if (sink.dispose) sink.dispose(); }
  };
}

Register it like any other renderer -- engine.addRenderer(createGLScrollRenderer({ field, sink, depth: 0.2 })) -- and see the demo's scene 3 (demo/scroll-rig.html) for it driving a live starfield behind the cards. The module imports only the headless lite-gl core, so the caller injects the browser-only WebGL2 sink; that is what lets test/recipes.test.js drive it in node against a fake sink (4096x render, upload called, uploaded Y monotonic in currentY, 0 B/op).

For React Three Fiber, the companion is @zakkster/lite-r3f-bridge -- recipes/r3f-bridge.jsx shows a useLiteInstancedAnimator wiring where a rig renderer feeds an ECS output buffer. That recipe is spelling-gate only: it is asserted ASCII + import-checked against the package card, but not executed in CI, because running it would pull React, three, and @react-three/fiber into a zero-dependency package just to prove a sample compiles.


The pipeline in one diagram

flowchart LR
  W[wheel / touch] --> V[VirtualScroll<br/>normalize + clamp -> targetY]
  V --> S[ScrollEngine<br/>spring chases targetY]
  S --> C[currentY<br/>smoothed, eased]
  C --> D[DOMScroller<br/>progress -> keyframes -> matrix3d]
  C --> R[your custom renderers]
  D --> B[DOMBinder<br/>single batched flush]

VirtualScroll owns the raw input. ScrollEngine owns the spring and the frame loop. Renderers receive the smoothed currentY and decide what to do with it -- DOMScroller is the built-in one, but any object with a render(currentY) method works.


How interpolation works

The raw targetY from VirtualScroll is stepped -- a mouse wheel emits discrete deltas, so the value jumps. It is never handed to renderers directly. Instead, ScrollEngine runs a spring (from @zakkster/lite-spring) whose target is chased each frame:

// inside ScrollEngine._tick(time)
this.spring.target = this.input.targetY;   // the jumpy goal
this.currentY = this.spring.update(dt);    // the smoothed, eased value

currentY is what every renderer receives and what feeds computeProgress / inverseLerp. It is a spring, not a lerp -- the difference matters for feel: a spring carries velocity, so a fast flick overshoots slightly and settles, where a fixed-rate lerp always eases identically regardless of input speed. Swap the character in one line via the preset option ("gentle", "snappy", ...) or inject your own spring instance.

Two guards wrap that step:

  • dt is clamped (default 50ms). A backgrounded tab that resumes after several seconds would otherwise feed the spring one enormous timestep and lurch the page. The clamp caps the catch-up.
  • prefers-reduced-motion bypasses the spring. When the user asks for reduced motion, currentY = targetY directly, so the page tracks input 1:1 with no easing.

What you get

  • ScrollEngine(target, options?) -- owns input + spring + frame loop. addRenderer, resize, start, destroy.
  • VirtualScroll(target, options?) -- normalizes wheel (with deltaMode handling) and touch (via a pinch-safe gesture) into a clamped targetY.
  • DOMScroller(elements, pool, options?) -- maps scroll progress to keyframed matrix3d and batches the flush. A ready-made renderer.
  • MetricsCache(elements, win?, options?) -- pre-measures absolute Y-bounds; optional ResizeObserver auto-invalidation.
  • composeMatrix2D(...) -- writes a column-major matrix3d into a buffer.
  • writeIntersectionBounds / calculateIntersectionBounds / computeProgress / computeParallaxOffset -- the spatial math, in zero-alloc and convenience forms.

Full type definitions ship in src/index.d.ts.


API reference

ScrollEngine(target, options?)

| Option | Default | Notes | | ----------------- | ------------------ | ------------------------------------------------------------ | | preset | "gentle" | lite-spring preset name | | maxDeltaTime | 0.05 | dt clamp in seconds | | getMaxScroll | documentElement | () => number; override the scroll ceiling | | reducedMotion | from matchMedia | force the reduced-motion bypass on/off | | keyboard | true | bind window keydown scrolling; false detaches only keydown | | multiplier | 1.2 | forwarded to VirtualScroll (wheel speed) | | touchMultiplier | 1.5 | forwarded to VirtualScroll (touch speed) | | input / spring / win / raf / cancelRaf / matchMedia | -- | injection points for testing |

addRenderer(renderer) registers { render(currentY), resize?() } and calls resize() once to seed layout. resize() recomputes the ceiling and re-measures renderers. start() snaps to window.scrollY and begins the loop. destroy() cancels the loop, drops renderers, and tears down the input.

VirtualScroll(target, options?)

Wheel is normalized by deltaMode (pixel / line x40 / page x800) then scaled by multiplier. Touch and pen route through @zakkster/lite-gesture's GestureTracker, which reports a per-frame frameDy and fires only for pans -- so a two-finger pinch never scrolls. targetY is clamped to [0, maxScroll]. createTracker is injectable.

DOMScroller(elements, pool, options?)

Expects the pool to hold elements.length * 4 rows in [tx, ty, scale, rotZ] order. Each frame, for each element: t = clamp(inverseLerp(enterY, exitY, currentY), 0, 1), evaluate the four rows at t, compose a matrix, and flush the batch through the binder. binder, win, observe, and ResizeObserverCtor are injectable/optional.

MetricsCache(elements, win?, options?)

measure() writes [enterY, exitY] per element into bounds. With { observe: true } it attaches a ResizeObserver that re-measures on element size changes and calls onResize.


The zero-GC hot path

DOMScroller.render(currentY) is the per-frame function. It:

  • reads bounds from a Float32Array (no layout read),
  • calls pool.eval(row, t) (cursor tracked in the pool's own typed array, no alloc),
  • writes into a pre-allocated matrix buffer via composeMatrix2D,
  • hands that same buffer to the binder each frame.

No closures, no intermediate objects, no per-frame arrays. The intersection and matrix math were verified allocation-free under --expose-gc during development. The design target is a full frame's headroom left for your own work inside the 16ms budget; measure against your own content and element count rather than trusting a synthetic number.


Resize invalidation

Bounds go stale whenever layout shifts. Two paths keep them fresh:

  • Window resize / orientation change -- call engine.resize() from your own listener; it re-measures every renderer.
  • Element size changes -- images finishing load, fonts swapping, content mutating. These do not fire a window resize, so pass { observe: true } to DOMScroller (or MetricsCache). A ResizeObserver then re-measures automatically and invalidates the binder. When one element changes height, everything below it shifts, so the whole set is recomputed, not just the observed element.

Accessibility

This rig hijacks wheel and touch input (preventDefault on wheel), which is inherent to smooth-scroll libraries. Three behaviors keep it usable rather than trapping:

  • Keyboard scrolling, on by default. A window keydown listener drives the arrow keys (+/-40 px), PageUp / PageDown and Space / Shift+Space (+/-0.9 x innerHeight), Home (top), and End (bottom). Focus in an interactive control -- INPUT, TEXTAREA, SELECT, BUTTON, or contenteditable -- suppresses the rig entirely, so typing and control operation are never stolen. The browser's own keyboard scroll is preventDefault-ed only when the rig owns the event target, so keys never double-count. Opt out with { keyboard: false } (native scroll reconciliation stays on).
  • Native scroll reconciliation -- native wins. A scrollbar drag or an in-page anchor jump (#section, scrollIntoView) moves the real window.scrollY; the rig reconciles its target to that position rather than fighting it. A jump of 200 px or more snaps the spring so the navigation lands in one frame; smaller moves ease smoothly.
  • Reduced motion. prefers-reduced-motion disables the spring out of the box (input tracks 1:1), honored live if the setting changes mid-session.

What it does not do: the rig is transform-only and never calls window.scrollTo, so it has no scrollbar of its own and does not synchronize the native scrollbar thumb to the animated position. The native scrollbar reflects the document's real position, not currentY. Still consider whether hijacked scroll is appropriate for your audience before shipping.


Testing

npm test            # full node:test suite (170 tests, under --expose-gc)
npm run torture     # node --expose-gc test/torture.mjs -- prints "ok" / exits 0
npm run verify      # test + torture (this gates prepublishOnly)
npm run test:coverage
npm run lane        # opt-in browser lane: DOM oracle in Chromium (Playwright)
npm run lane:alloc  # opt-in browser lane: frame-loop allocation in Chromium

Every module is covered with injected fakes -- a fake gesture factory, spring, window, rAF, binder, and ResizeObserver -- so the core suite runs with no DOM and no browser. The recipe tier (test/recipes.test.js, part of npm test) proves the ecosystem wiring at four honest levels: gl-sync and keyboard-anchor run in full against fakes, profiler-phases runs against the real profiler, and r3f-bridge is a spelling gate (ASCII + card-checked imports, not executed). SCROLL_RIG_RECIPE_BREAK=1 npm test fails the gl-sync tier on purpose -- a tier that cannot fail is decorative.

The browser lane under test/browser/ (npm run lane / npm run lane:alloc) runs green in a real Chromium: the real rig loads as ES modules through a disk-served import map, and the matrix3d the real DOMBinder writes matches the headless recomputation within 0.02 across wheel / keyboard / touch. SR-01 is proven in vivo -- under a live transform the offsetTop source does not drift (0.000px) while the v1.0.0 gBCR control drifts 3000px. The frame loop sits at the allocation floor: the rig compute path samples 0 B over a 2400-frame burst while an allocating control renderer samples ~66 KB on the same path. The lane is opt-in and never gates publish; publish is gated by the node lanes (npm test + npm run torture). See test/browser/honesty.md for the exact captured output.


What this is not

  • Not a native-scroll polyfill. It replaces native scroll with a virtual one; it does not enhance the browser's own scrolling.
  • Not a layout or pinning framework. It drives transforms from a scroll value. Sticky/pinned sections, horizontal galleries, etc. are things you compose on top.
  • Not a physics engine. The spring is a single 1-D critically-tunable spring for scroll feel, nothing more.
  • Not batteries-included for opacity/filters. The built-in renderer animates matrix3d (translate/scale/rotate). Other properties are a custom renderer away.

Ecosystem

Built on the @zakkster zero-GC stack (the five runtime dependencies): lite-spring (interpolation) * lite-keyframe (track evaluation) * lite-gesture (pinch-safe touch) * lite-dom-binder (batched transforms) * lite-lerp (clamp / inverseLerp)

Optional companions (used only by the repo-only recipes and demo -- not runtime dependencies of this package): lite-gl (scroll-synced WebGL, recipes/gl-sync.js) * lite-r3f-bridge (React Three Fiber variant, recipes/r3f-bridge.jsx) * lite-profiler (per-phase frame timing, recipes/profiler-phases.js). These stay out of package.json dependencies and out of the published pack.


A note on the name

The package is @zakkster/lite-scroll-rig-pro. The -pro suffix disambiguates a crowded, generic name -- it is not a paid tier, and there is no free edition. The full rationale (and the trigger that would reverse it) is recorded in decisions/0005-naming.md.


License

MIT (c) Zahary Shinikchiev