@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.
Maintainers
Readme
@zakkster/lite-scrollforge
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
Float64ArrayLUTs, integer switch dispatch on parsed range endpoints, and one style-write per element per frame on the polyfill. - 115 tests,
node:testonly. Bothnpm testandnpm run test:gc(--expose-gc) pass.
npm i @zakkster/lite-scrollforgeQuickstart
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
- The storyboard shape
- Emitting CSS
- Attaching
- Easings
- Sequencing many tracks
- Export targets
- Ecosystem composition
- Zero-GC discipline
- Browser support
- API reference
- License
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 linearPointsSampling 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 CSSlinear(...)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 oftoRig().@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-propertiesin keyframes to animate OKLCH color space parameters, downstream CSS usescolor: 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 parallelFloat64Array+string[]arrays. Hot loop is a dense indexedfor— V8 keeps it monomorphic. _computeRangeBoundsInto()writes into state. Mutatesstate.rangeStart/state.rangeEnddirectly rather than returning a fresh{ start, end }object each observer tick.- Transform scratch object. Transform components (translateX, scale,
rotate) aggregate into a persistent
_scratchstruct at module scope; flushed as a singlestyle.transformstring write per element per frame. _IO_THRESHOLDS. 257-slotIntersectionObserverthreshold 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 coverageLicense
MIT © Zahary Shinikchiev
