@vysmo/scroll Library DOM

Scroll for the modern web.

Three primitives that bind scroll progress to the rest of the ecosystem: createScrollProgress (raw 0–1 emitter), createScrollTransition (drives any @vysmo/transitions render), createScrollEffect (drives any @vysmo/effects params). One shared rAF-throttled observer underneath.

  • 3 primitives
  • ~1 KB gzipped
  • 1 rAF shared observer
  • headless you own the canvas
$ pnpm add @vysmo/scroll View on GitHub

Install

Headless, SSR-safe, and narrow on purpose. The only dependencies are the runner types from @vysmo/transitions and @vysmo/effects — install whichever you'll actually use.

pnpm add @vysmo/scroll @vysmo/transitions @vysmo/effects npm install @vysmo/scroll @vysmo/transitions @vysmo/effects yarn add @vysmo/scroll @vysmo/transitions @vysmo/effects

One shared rAF-throttled observer underneath. Window scroll / resize listeners attach on first subscribe and detach when the last subscription is destroyed — so the cost of importing the module is zero until you actually call a primitive.

Quick start

The simplest primitive: createScrollProgress turns a section's scroll-past into a stream of [0, 1] values. Pipe it into anything — a CSS variable, a transform, your own renderer.

import { createScrollProgress } from "@vysmo/scroll";
import { power2Out } from "@vysmo/easings";

// 1. Pick an element. Progress = 0 when its top hits the viewport's
//    bottom; 1 when its bottom passes the viewport's top.
const section = document.querySelector<HTMLElement>("#hero")!;

// 2. Subscribe. The returned handle's destroy() unsubscribes — call
//    it on unmount so you don't leak observers.
const handle = createScrollProgress({
  element: section,
  ease: power2Out,         // optional remap; default is linear
  onProgress: (p) => {
    section.style.setProperty("--p", String(p));
  },
});

// 3. ...later, on teardown:
handle.destroy();

Three things to remember. The span is a full viewport sweep — 0 when the element's top reaches the viewport's bottom; 1 when its bottom passes the viewport's top. Pass ease to remap the curve (any function works; everything in @vysmo/easings just does). Always destroy the handle on unmount — otherwise the observer keeps a reference to the element and your callback keeps firing.

Concepts

A short mental model before the API ref. Skip if you've already played with the live demo.

Scroll is a normalised range

Every primitive emits a value in [0, 1] derived from the same formula: how far the element has swept across the viewport, from "just entered the bottom" to "just left the top." That single shape composes with anything — easings, transitions, effects, your own values.

You own the canvas

createScrollTransition and createScrollEffect take a runner — the WebGL2 context's owner. You construct it; you dispose it. The scroll package never allocates a context, never reaches into the runner's internals. That makes it composable: one runner can serve many scroll bindings, and the lifecycle stays in your hands.

Envelopes shape the response

Raw progress is rarely the curve you want. The zone helpers (scrollRange, scrollZones, scrollPlateau) reshape [0, 1] with a clear-zone where the output stays flat — so a transition holds its final frame across the middle of a section, or an effect ramps in / out without polluting the centre. smoothstep is the default ramp.

One observer for the whole page

Every primitive subscribes to the same shared ScrollObserver. One scroll listener, one resize listener, one requestAnimationFrame per frame. Add or remove subscriptions freely — the observer attaches/detaches window listeners as the subscriber count crosses zero.

De-duped on equality

When the mapped progress equals the previous mapped value, the subscriber is not invoked — including the runner.render() call inside createScrollTransition and createScrollEffect. Idle frames are free.

SSR-safe at module load

No DOM access at import time. The shared observer is lazily constructed on first subscribe(), and window listeners only attach when there's at least one subscriber. Safe to import server-side without guards.

API reference

Three primitives, one observer, four envelope helpers.

createScrollProgress

Subscribe an element. onProgress fires whenever the mapped progress changes. The returned handle's destroy() unsubscribes. Repeated equal values are de-duped.

import { createScrollProgress } from "@vysmo/scroll";

interface ScrollProgressOptions {
  // Element whose bounding box is tracked.
  element: HTMLElement;
  // Optional remap of the raw [0, 1] progress. Default: linear.
  ease?: (t: number) => number;
  // Invoked whenever progress changes (de-duped — repeated equal
  // values do not re-fire).
  onProgress: (progress: number) => void;
}

declare function createScrollProgress(opts: ScrollProgressOptions): {
  destroy(): void;
};

createScrollTransition

Drive a transition's progress through the section's sweep. You hand in the runner; we wire the render() call. Multiple scroll-transitions can share one runner.

import { createScrollTransition, scrollPlateau } from "@vysmo/scroll";
import { Runner, crossZoom } from "@vysmo/transitions";

const runner = new Runner({ canvas });

// Drive a transition's progress through the section's scroll sweep.
const handle = createScrollTransition({
  section,                 // section whose scroll-past drives the run
  runner,                  // YOU own the canvas; you own the runner
  transition: crossZoom,   // any @vysmo/transitions shader
  from, to,                // TextureSource pair (image, canvas, video)
  params: { speed: 1.2 },  // optional uniform overrides
  ease: scrollPlateau(0.3, 0.7),
});

// handle.destroy() unsubscribes; the runner you passed in is
// untouched — dispose it on your own schedule.

createScrollEffect

Drive an effect's params from progress. paramsAt decouples the scroll package from effect-specific knowledge — the caller maps progress to whichever uniform makes sense.

import { createScrollEffect, scrollZones } from "@vysmo/scroll";
import { Runner, blur } from "@vysmo/effects";

const runner = new Runner({ canvas });

// Drive an effect's params from scroll progress through your envelope.
// "paramsAt" decouples the scroll package from effect-specific knowledge:
// the caller picks which param is animated and how.
const handle = createScrollEffect({
  section,
  runner,
  effect: blur,
  source,                                  // image / canvas / video
  paramsAt: (p) => ({ radius: p * 24 }),
  ease: scrollZones(0.25, 0.75),           // bathtub: ramp in / clean / ramp out
});

Envelopes — scrollRange / scrollZones / scrollPlateau

Pure functions that reshape [0, 1]. Use them as the ease argument on any primitive (scrollPlateau for transitions that hold mid-section, scrollZones for effects that go quiet in the middle, scrollRange for clamped sub-ranges). smoothstep is the default ramp shape.

import { scrollRange, scrollZones, scrollPlateau, smoothstep } from "@vysmo/scroll";

// Clamp progress to a sub-range. Outside it, output stays flat.
//   scrollRange(0.1, 0.5) — plays between 10% and 50%, stays at the
//   end state past 50%.
scrollRange(start, end, ease?);

// Three-zone bathtub envelope. 1 → 0 → 1 across the section.
//   scrollZones(0.25, 0.85) — effect at full at the entry, zero
//   through the clear zone, full again at exit.
scrollZones(clearStart, clearEnd, ease?);

// Three-zone plateau envelope — the inverse of zones.
//   scrollPlateau(0.3, 0.7) — 0 → 1 entering, hold at 1 across the
//   plateau, 1 → 0 exiting.
scrollPlateau(clearStart, clearEnd, ease?);

// The default ramp shape used by all three zone helpers — C¹-continuous
// S-curve that decelerates smoothly into the plateaus.
smoothstep;  // (t) => t * t * (3 - 2t)

sharedScrollObserver / ScrollObserver

The singleton (use it for everything) and the class (for isolated observers in unusual cases). flush() is exposed so callers can force a redraw after changing local state (e.g., a slider that reshapes an envelope) without waiting for the next scroll event.

import { sharedScrollObserver, ScrollObserver } from "@vysmo/scroll";

// One singleton observer per page is enough for any number of
// subscribers. The window listeners attach on first subscribe and
// detach when the last subscriber unsubscribes — so importing this
// module costs nothing if you never call subscribe().
const observer = sharedScrollObserver();

const unsubscribe = observer.subscribe(element, {
  onScroll(rect, viewport) {
    // rect:    DOMRect of element
    // viewport: { width, height } of window
    // Called once per rAF; identical positions are not de-duped here —
    // callers do that themselves (see createScrollProgress).
  },
});

// observer.flush() runs every subscriber synchronously with the
// current scroll position — useful for forcing a redraw after
// changing local state (e.g., a slider that reshapes an envelope).

// Need an isolated observer instead of the singleton? Construct one:
const local = new ScrollObserver();

Types

EaseFn = (t: number) => number — structurally compatible with @vysmo/easings.EasingFn, kept local so this package has zero runtime coupling. Handle = { destroy(): void } — the uniform return shape of every primitive. Always call destroy() on unmount.

Recipes

Common patterns. Each one stands alone — copy, adapt, ship.

Parallax layer

A small helper that translates an element vertically by a fraction of its sweep distance. Compose any number of these — they all share the one rAF observer underneath.

import { createScrollProgress } from "@vysmo/scroll";
import { power1Out } from "@vysmo/easings";

// Translate an element vertically by a fraction of its sweep distance.
// Wrap each layer in its own subscription — any number of parallax
// layers share the same rAF observer under the hood.
function parallax(el: HTMLElement, distance = 80) {
  return createScrollProgress({
    element: el,
    ease: power1Out,
    onProgress: (p) => {
      // Centre at progress 0.5, scaled by `distance` pixels.
      const y = (p - 0.5) * 2 * distance;
      el.style.transform = `translate3d(0, ${y}px, 0)`;
    },
  });
}

Scroll-driven flipbook

Pipe scroll progress straight into flipbook.seek() for cinematic page reveals tied to scroll position. Disable the flipbook's built-in drag nav so the user's drag doesn't compete with the scroll-driven curl.

import { createScrollProgress } from "@vysmo/scroll";
import { createFlipbook } from "@vysmo/flipbook";

// Drive a flipbook off scroll. Disable the built-in drag nav so the
// user's drag doesn't fight the scroll-driven curl.
const flip = createFlipbook({
  container,
  pages: heroPhotos,
  dragNavigation: false,
});

createScrollProgress({
  element: container,
  onProgress: (p) => flip.seek(p),  // 0 = idle, 1 = commit to next
});

Sticky pin + scroll-driven animation

Pin a layer with position: sticky; drive its inner animation with createScrollProgress. The CSS handles the pin; the JS handles the timing.

import { createScrollProgress } from "@vysmo/scroll";

// "Sticky pin" pattern: lock a layer to the section while scrolling
// and animate something inside it. Use position: sticky on the layer
// for the visual pin; createScrollProgress for the timing.
//
// .pin {
//   position: sticky; top: 0; height: 100vh;
// }

createScrollProgress({
  element: section,
  onProgress: (p) => {
    inner.style.transform = `scale(${1 + p * 0.4})`;
  },
});

Respect reduced motion

When the user has reduced motion enabled, flatten the envelope to a constant so the effect stays at identity. The respectReducedMotion wrapper keeps the call site clean.

import { createScrollEffect, scrollZones } from "@vysmo/scroll";
import { respectReducedMotion, linear } from "@vysmo/easings";

// When prefers-reduced-motion is set, we want the effect to stay at
// identity throughout — flatten the envelope to constant zero.
const ease = respectReducedMotion(scrollZones(0.25, 0.75), () => 0);

createScrollEffect({
  section, runner, effect, source,
  paramsAt: (p) => ({ radius: p * 24 }),
  ease,
});

FAQ & gotchas

The handful of things that catch people the first time they ship a scroll-driven page.

Why doesn’t my scroll-driven transition repaint when I tweak settings?

The observer only re-runs subscribers when the scroll position changes. If you change a parameter (the easing, an envelope, a uniform) and want the canvas to repaint at the current scroll position, call sharedScrollObserver().flush(). Subscribing for the first time is itself a flush; the playground demo uses this trick to make slider drags feel instant.

Should I create one Runner or many?

One per canvas. WebGL contexts are expensive to construct and quota-limited (~16 per page in Chromium). The scroll package never creates one for you — you decide when to construct and dispose.

// ✅ One Runner, many scroll-bindings.
const runner = new Runner({ canvas });
createScrollTransition({ section: heroSection, runner, ... });
createScrollTransition({ section: storySection, runner, ... });

// On unmount: destroy your scroll handles, dispose the runner.
heroHandle.destroy();
storyHandle.destroy();
runner.dispose();

Can I use this without @vysmo/transitions / @vysmo/effects?

Yes. createScrollProgress has no runtime coupling to either — it just emits numbers. Use it to drive CSS variables, transforms, your own canvas renderer, or anything else. The transitions / effects packages are only needed if you call the dedicated bindings.

Does it support horizontal scroll?

No. Progress is computed off the element's vertical position relative to the viewport. For horizontal-section carousels, subscribe to the observer directly with sharedScrollObserver().subscribe() and read rect.left / rect.right.

What about IntersectionObserver?

IntersectionObserver fires at threshold crossings, not on every frame — great for lazy loading, wrong for continuous scroll-driven animation. Our shared ScrollObserver uses requestAnimationFrame against a passive scroll listener, which gives smooth per-frame updates without paying for an IO per element.

Mobile / touch behaviour?

Mobile scroll fires the same scroll event as desktop — the observer works without any special handling. The bigger mobile concern is the iOS rubber-band area at the top / bottom of the page: the observer keeps emitting during the rubber-band so progress can briefly read slightly out of [0, 1] bounds (we clamp before calling your callback).