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-scrollforge

v1.0.0

Published

Zero-GC, zero-dependency single-file ESM authoring layer for CSS Scroll-Driven Animations. Compile storyboards to native CSS, GSAP, or lite-scroll-rig-pro; polyfill runtime for older browsers.

Readme

@zakkster/lite-scrollforge

npm version Zero-GC sponsor npm bundle size npm downloads npm total downloads TypeScript Dependencies License: MIT

Zero-GC · zero-dependency · single-file ESM.

A CSS Scroll-Driven Animation authoring layer. Author animations as a config object; compile to native CSS, GSAP, or @zakkster/lite-scroll-rig-pro; drop in the polyfill runtime for browsers without native support.

  • ~1750 LOC, one file, ASCII source. No transpile step, no bundler required, no runtime dependencies.
  • 15 exports. Covers CSS emission, easing math, sequencing, three export targets, and a universal attach entry point.
  • Zero-GC hot path. Compositor-driven on native browsers; pre-allocated Float64Array LUTs, integer switch dispatch on parsed range endpoints, and one style-write per element per frame on the polyfill.
  • 115 tests, node:test only. Both npm test and npm run test:gc (--expose-gc) pass.
npm i @zakkster/lite-scrollforge

Quickstart

import { attachStoryboardRuntime } from '@zakkster/lite-scrollforge';

attachStoryboardRuntime({
    tracks: [{
        selector: '.hero-title',
        timeline: { kind: 'view' },
        range:    { start: 'entry 0%', end: 'entry 100%' },
        keyframes: [
            { opacity: 0, translateY: 30 },
            { opacity: 1, translateY: 0 }
        ],
        easing: 'easeOutCubic'
    }]
});

That's the whole minimal example. attachStoryboardRuntime auto-detects native scroll-driven support (via HAS_NATIVE_SUPPORT) and either injects pure CSS (zero JS in the animation loop) or installs the polyfill runtime transparently. Detach with the returned handle:

const handle = attachStoryboardRuntime(sb);
// ... later
handle.detach();

Table of contents

Concepts

CSS Scroll-Driven Animations tie the progress of a CSS animation to scroll position instead of wall-clock time. There are two flavors:

  • View timeline — progress driven by an element's position in the viewport. Ranges: entry, contain, exit, cover.
  • Scroll timeline — progress driven by a scroll container's scroll position. Ranges: bare percentages.

Native support is baseline-ish (Chrome 115+, Edge 115+, Opera 101+). Firefox has it behind a flag. Safari is in progress. Scrollforge lets you author once and target all four (native CSS + polyfill + GSAP + rig).

The storyboard shape

const storyboard = {
    // Optional: named timelines declared on ancestors, reused across tracks
    timelines: {
        '--gallery': {
            attachedSelector: '.gallery',
            kind: 'view',
            axis: 'block'
        }
    },
    // One entry per animated element
    tracks: [{
        selector: '.gallery .card-1',
        timeline: { name: '--gallery' },      // references the named timeline
        range:    { start: '0%', end: '33.33%' },
        keyframes: [
            { opacity: 0, translateY: 20 },
            { opacity: 1, translateY: 0 }
        ],
        easing: 'easeOutCubic'
    }, /* card-2, card-3, ... */]
};

Every track needs: selector, keyframes (at least 2), and optionally timeline / range / easing / fill.

Keyframes accept: opacity, transform components (translateX/Y/Z, scale, scaleX/Y, rotate), any CSS property, and CSS custom properties (--foo).

Emitting CSS

storyboardToCss(sb) returns a string:

import { storyboardToCss } from '@zakkster/lite-scrollforge';

const css = storyboardToCss({
    tracks: [{
        selector: '.card',
        timeline: { kind: 'view' },
        keyframes: [{ opacity: 0 }, { opacity: 1 }]
    }]
});
// @keyframes _sf_kf_1 { 0% { opacity: 0; } 100% { opacity: 1; } }
// .card { animation-name: _sf_kf_1; ... }

Inject it yourself, or use attachStoryboard to inject via a <style> element:

import { attachStoryboard } from '@zakkster/lite-scrollforge';
const handle = attachStoryboard(storyboard);
// -> { detach(), styleElement }

attachStoryboard is the native-only path — zero JS in the animation loop, the compositor handles everything. Use attachStoryboardRuntime when you want the polyfill fallback.

Attaching

import { attachStoryboardRuntime, HAS_NATIVE_SUPPORT } from '@zakkster/lite-scrollforge';

const handle = attachStoryboardRuntime(sb);            // auto
const handle = attachStoryboardRuntime(sb, { runtime: 'polyfill' });  // force
const handle = attachStoryboardRuntime(sb, { runtime: 'native' });    // force

if (!HAS_NATIVE_SUPPORT) console.warn('polyfill active');

The polyfill path uses IntersectionObserver for view timelines and passive scroll listeners for scroll timelines. Both are coalesced through rAF. Range endpoints are pre-parsed at attach time into an integer-kind struct; the frame loop is a switch on the kind, no regex or string allocations.

Easings

Three ways to specify an easing on a track:

Preset name from CSS_EASINGS (5 CSS keywords + 24 Penner presets):

{ easing: 'easeOutCubic' }
{ easing: 'easeInOutBack' }
{ easing: 'ease-in-out' }

Raw CSS string (passed through):

{ easing: 'cubic-bezier(0.5, 0, 0.5, 1)' }
{ easing: 'linear(0, 0.5, 1)' }

A function (t) => number — including analytic easings from @zakkster/lite-ease that can't be represented as a single cubic-bezier:

import { easeOutBounce } from '@zakkster/lite-ease';
{ easing: easeOutBounce }
// -> emits `animation-timing-function: linear(0, 0.019, ...)` via linearPoints

Sampling utilities

import { linearPoints, easingToCssTimingFunction, cubicBezierCss } from '@zakkster/lite-scrollforge';
import { easeInOutElastic } from '@zakkster/lite-ease';

linearPoints(easeInOutElastic, 32);
// 'linear(0, 0.001, -0.002, ..., 1)'

easingToCssTimingFunction('easeOutBack');
// 'cubic-bezier(0.34, 1.56, 0.64, 1)'

easingToCssTimingFunction(easeInOutElastic);
// 'linear(0, 0.001, ..., 1)'

cubicBezierCss(0.5, 0, 0.5, 1);
// 'cubic-bezier(0.5, 0, 0.5, 1)'

Sequencing many tracks

The common "N cards fade in as you scroll through a section" pattern:

import { sequenceOnTimeline, storyboardToCss } from '@zakkster/lite-scrollforge';

const tracks = sequenceOnTimeline([
    { selector: '.gallery .card-1', timeline: { name: '--gallery' },
      keyframes: [{ opacity: 0 }, { opacity: 1 }], easing: 'easeOutCubic' },
    { selector: '.gallery .card-2', timeline: { name: '--gallery' },
      keyframes: [{ opacity: 0 }, { opacity: 1 }], easing: 'easeOutCubic' },
    { selector: '.gallery .card-3', timeline: { name: '--gallery' },
      keyframes: [{ opacity: 0 }, { opacity: 1 }], easing: 'easeOutCubic' }
], { overlap: 0.15 });   // 15% cross-fade

const css = storyboardToCss({
    timelines: {
        '--gallery': { attachedSelector: '.gallery', kind: 'view', axis: 'block' }
    },
    tracks
});

Options: overlap (0..1, default 0), startPct / endPct bounds, rangeName prefix (entry, contain, exit, cover).

Export targets

Both target-emitters return a string of runnable JavaScript. Ship it as a .js file, or paste into your project.

GSAP + ScrollTrigger

import { toGsap } from '@zakkster/lite-scrollforge';
console.log(toGsap(storyboard));
// generated
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);

export function attachScrollAnimations() {
    const animations = [];
    // Track 0: .card
    animations.push(gsap.fromTo(".card",
        { opacity: 0, y: 30 },
        {
            opacity: 1,
            y: 0,
            ease: "power2.out",
            scrollTrigger: {
                trigger: ".card",
                start: "0% bottom",
                end: "80% bottom",
                scrub: true
            }
        }));
    return function detach() { /* ... */ };
}

Property renames applied (translateX → x, rotate → rotation), 27-entry easing map to GSAP built-ins. Raw cubic-bezier(...) and function easings emit with a /* needs CustomEase */ marker.

lite-scroll-rig-pro

import { toRig } from '@zakkster/lite-scrollforge';
console.log(toRig(storyboard));

Rig supports 4 property slots per element (translateX / translateY / scale / rotate). Other props emit as a "dropped" comment. Range endpoints pre-scale into the rig's t ∈ [0, 1] axis (cover exact; entry/exit approximated at 0.5-split with a warning).

Ecosystem composition

Scrollforge is one piece of the @zakkster/* ecosystem. Composition happens through userland — Scrollforge itself ships with zero runtime dependencies.

  • @zakkster/lite-ease — 30 Penner easing functions. Robert Penner himself starred it. Pass its functions directly to any easing-accepting Scrollforge API; analytic curves (Bounce, Elastic) that cubic-bezier can't represent get emitted as CSS linear(...) automatically.
  • @zakkster/lite-cubic-bezier — zero-GC bezier runtime with a DoD coefficient compiler. Scrollforge ships an inline evaluator to preserve its zero-dep promise; use lite-cubic-bezier when you need shared curve state across multiple contexts.
  • @zakkster/lite-scroll-rig-pro — target of toRig().
  • @zakkster/lite-signal — reactive primitives. Wire Scrollforge into a reactive UI: signals drive the storyboard config, effects detach + reattach on config changes.
  • @zakkster/lite-color-engine — pair with --custom-properties in keyframes to animate OKLCH color space parameters, downstream CSS uses color: oklch(var(--l) var(--c) var(--h)).

Example — animating a color via a custom property, with lite-color-engine consuming it downstream:

attachStoryboardRuntime({
    tracks: [{
        selector: '.sunset',
        timeline: { kind: 'view' },
        keyframes: [
            { '--l': 0.35, '--c': 0.08, '--h': 20  },
            { '--l': 0.85, '--c': 0.20, '--h': 240 }
        ],
        easing: 'linear'
    }]
});
.sunset {
    color: oklch(var(--l) var(--c) var(--h));
}

Zero-GC discipline

The claim: zero allocations in the animation frame loop, on both the native path (compositor thread) and the polyfill path (JS-driven).

Native path. Pure CSS injection. The compositor handles everything. No JS runs during scroll.

Polyfill path. Every allocation-risk was audited and eliminated:

  • Range endpoints parsed once at attach. 'entry 25%'{ kind: 3, pct: 0.25 }. Hot-path resolution is an integer switch, not a string compare or regex.
  • Parallel arrays instead of for..in. Keyframe values stored as parallel Float64Array + string[] arrays. Hot loop is a dense indexed for — V8 keeps it monomorphic.
  • _computeRangeBoundsInto() writes into state. Mutates state.rangeStart / state.rangeEnd directly rather than returning a fresh { start, end } object each observer tick.
  • Transform scratch object. Transform components (translateX, scale, rotate) aggregate into a persistent _scratch struct at module scope; flushed as a single style.transform string write per element per frame.
  • _IO_THRESHOLDS. 257-slot IntersectionObserver threshold array pre-allocated once at module load, shared across every polyfilled track.

The single unavoidable per-frame allocation is the element.style.transform string when any transform component changes — a DOM boundary that requires a string. This is the theoretical minimum for JS-driven CSS animation.

Browser support

Native path (via HAS_NATIVE_SUPPORT):

| Browser | Support | |--------|-----| | Chrome | 115+ | | Edge | 115+ | | Opera | 101+ | | Firefox | Behind flag | | Safari | In progress |

Polyfill path — anywhere IntersectionObserver is available (all evergreen browsers, IE11 with polyfill). Also transparently falls back to passive-scroll + getBoundingClientRect() if IntersectionObserver is missing.

API reference

Full TypeScript declarations live in Scrollforge.d.ts. Summary:

| Export | Signature | |---|---| | trackToCss | (track: Track) => { keyframesName, keyframesCss, ruleCss } | | storyboardToCss | (sb: Storyboard, opts?) => string | | attachStoryboard | (sb, root?) => { detach, styleElement } | | resetKeyframeCounter | () => void | | HAS_NATIVE_SUPPORT | boolean | | CSS_EASINGS | Readonly<Record<string, string>> | | EASINGS | (deprecated alias) | | cubicBezierCss | (x1, y1, x2, y2) => string | | cubicBezier | (deprecated alias) | | linearPoints | (fn, samples?) => string | | easingToCssTimingFunction | (input, samples?) => string | | sequenceOnTimeline | (tracks, opts?) => Track[] | | toGsap | (sb, opts?) => string | | toRig | (sb, opts?) => string | | attachStoryboardRuntime | (sb, opts?) => { detach } |

Testing

npm test              # 115 tests
npm run test:gc       # same suite with --expose-gc
npm run test:coverage # node's built-in coverage

License

MIT © Zahary Shinikchiev