Driving page animation from scroll position
Almost every scroll effect on the web is the same three lines wearing different clothes: read the scroll position, turn it into a number between 0 and 1, feed that number to some style. The libraries that do this for you are mostly a scheduler, an easing table and a plugin system wrapped around those three lines. On a page that is already running a forged CubeScroll module you need none of it, because the module has already done the hard part — the listener, the clamping, the rounding, the reduced-motion floor — and it publishes the result. Your job is to read the clock, not to build a second one.
That distinction matters more than it sounds. Two timelines on one page
do not stay in step: they round differently, they sample on different
events, one of them honours a visitor's motion preference and the other
quietly does not, and the drift only shows up on somebody else's laptop.
What follows is how to ride the published clock, and — just as importantly —
when to ignore this page and reach for animation-timeline or an
IntersectionObserver instead.
What the module publishes
A forged module computes its progress from
window.scrollY over the assembly track, which is the emitted
scroll length (--scroll, default 400vh) minus one viewport. The
result is clamped to 0–1, rounded to four decimal places, and then published
two ways, both carrying the same number:
--assembly, a custom property set on<html>— so:rootin your stylesheet — as a unitless string with four decimals:0.0000at the top of the track,1.0000when the picture is held.pixelScroll, aCustomEventdispatched onwindowwith the same value ondetail.assembly.
Both are written by one function behind one guard: if the rounded value has not changed, nothing is written and nothing is dispatched. You are not being handed a flood of events at the browser's scroll rate; you are handed one per distinguishable step of the track. That is also why the CSS and the JS can never disagree — the property is the rounded number stringified, and the event carries the number that was rounded.
One honest caveat about what the number means. It is the scroll
target, not the camera. The camera chases the target with damping
(--damp, default 0.12 per frame), so during a fast flick your
CSS arrives fractionally before the photograph does. For captions, section
fades and pinned copy this is invisible and arguably correct — the type
should not feel like it is dragging a weight. Nothing locked to the camera
to the pixel is on offer: the progress the shader reads is not a seam.
Pure CSS: a caption that arrives with the picture
The property is a plain number token, so it can be used in
calc() anywhere a number is allowed, and multiplied by a unit
anywhere a length is. Nothing is registered with @property,
which has one practical consequence: you cannot transition or animate
--assembly itself. You do not need to — the scroll is the
animation.
/* Each caption settles over its own third of the track, offset by an
--i index set in the markup (style="--i: 0", 1, 2 …) so a run of
them staggers with no extra JavaScript. clamp() with unitless
numbers keeps every one of them at exactly 0 before its turn and
exactly 1 after it. */
.caption {
--start: calc(0.33 + var(--i, 0) * 0.08);
--span: 0.33;
--p: clamp(0, calc((var(--assembly, 1) - var(--start)) / var(--span)), 1);
opacity: var(--p);
transform: translate3d(0, calc((1 - var(--p)) * 1.5rem), 0);
filter: blur(calc((1 - var(--p)) * 3px));
}
@media (prefers-reduced-motion: reduce) {
.caption { opacity: 1; transform: none; filter: none; }
}
Note the fallback in var(--assembly, 1), and note that it is
1 rather than 0. On a cubes page the property does
not exist until the module publishes for the first time, and on a page where
the script never runs at all it never exists. A fallback of 0 means that
page shows a blank space where the caption should be; a fallback of 1 shows
it resolved, which is the honest resting state. (A kinetic page is the
exception
that proves the rule: it sets the property to 0.0000 at module
top level, before three.js is even fetched, precisely so its hero type does
not flash resolved and then get thrown back.)
The composed --theme pages do exactly this and no more: a
kinetic hero splits its headline into per-word spans carrying an
--i index, each settling on its own share of the assembly, with
no page script at all. Note what that does and does not cover: on a kinetic
page --assembly still runs with the scroll and still paces the
copy, but the picture is driven by the pointer, not the scroll, so do
not pace anything against the smear. View source on one before you write your
own.
JavaScript: the work CSS cannot do
Reach for the event when the thing you want is not a style — a counter, a class toggle that drives unrelated rules, lazily starting something once the picture has landed.
const cues = [...document.querySelectorAll('[data-at]')]
.map((el) => ({ el, at: Number(el.dataset.at) }));
function apply(assembly) {
for (const cue of cues) {
cue.el.classList.toggle('is-past', assembly >= cue.at);
}
document.querySelector('.readout').textContent =
Math.round(assembly * 100) + '%';
}
window.addEventListener('pixelScroll', (e) => apply(e.detail.assembly));
/* If your script loads after the module's first publish, read the
current value instead of waiting for the next scroll. The module
writes it inline on the root element, so .style is enough. */
const now = document.documentElement.style.getPropertyValue('--assembly');
apply(now === '' ? 1 : Number(now));
Do not add a requestAnimationFrame loop around this. The
event only fires when the rounded value changed, which is already the
coalescing you would otherwise be writing. If the handler is too heavy to
run inline, that is a signal the work belongs in CSS.
The two states that are not scrolling
Anything paced on the assembly inherits two states for free, and gets them wrong if it fights them.
The first is stillness. The module holds the picture assembled when the
operating system reports prefers-reduced-motion: reduce, and a
page may add its own hold by dispatching pixelScroll:motion with
{ still: true } — for a modal, a video, a toggle in your own UI.
The two are OR-ed, never swapped: your toggle can add stillness, but the
visitor's preference is the floor and nothing on the page can put
scroll-coupled motion back in front of someone who asked for less of it.
When the hold applies, the module parks the camera and publishes
1. Your listener therefore needs no special case: it is told the
resting state through the same seam as everything else. The CSS side does
need the media query shown above, because a CSS rule that moves type is your
motion, not the module's.
window.dispatchEvent(new CustomEvent('pixelScroll:motion', {
detail: { still: true } // false releases it again
}));
Dispatch that whenever your state changes rather than once at load: the listener is installed when the module boots, which is after three.js resolves, so a single early dispatch can land on nothing.
The second state is the fallback. With no WebGL context, or no reachable
three.js host, the module mounts the photograph as a still, sets
data-forge-fallback on <html> and publishes
assembly 1. Content keyed to the clock lays out over a resolved
image instead of waiting for one that is never coming. Treat it as a design
state: add an html[data-forge-fallback] rule if anything needs
to sit differently over a still, and check the page once with WebGL turned
off.
When this is the wrong approach
This clock is about one thing: how far the picture has assembled. If what you want has nothing to do with the picture, do not borrow its number.
CSS scroll-driven animations.
animation-timeline: scroll() and view() let the
browser drive a keyframe animation from the scroll position or from an
element's progress through the viewport, off the main thread, with no script
at all. For a progress bar at the top of a long article, or a card that
should animate as it crosses the fold three screens below the track, that is
strictly better than anything here — it is less code and it runs where your
JavaScript cannot stall it. The catch is that it is a genuinely separate
clock: it reads the document's scroll directly and knows nothing about the
module's damping, its reduced-motion hold or its fallback. Use it for
content that is independent of the picture; use --assembly for
content that is meant to arrive with the picture. Support landed in
Chromium first and has been filling in elsewhere since, so check current
support and wrap it in @supports (animation-timeline: scroll())
rather than assuming.
IntersectionObserver. If the question is "has this section arrived" rather than "how far along are we", an observer is the right answer and a continuous timeline is the wrong one. It is supported everywhere, it costs nothing while nothing is intersecting, and it fires once per threshold crossing instead of continuously. One-shot reveals, lazy-loading, sticky headers that change at a boundary: observer.
A scroll listener and rAF. Still the answer when you need a number the module does not publish — each element's own progress through the viewport, say, rather than the track's. Write it with a passive listener that sets a flag and a single rAF that reads layout, and be aware you are adding a second timeline with all the drift that implies.
And one case where none of this applies: content parked below the
assembly track. Progress is clamped, so it reads 1.0000 for the
entire rest of the document. A call to action down there cannot be paced on
the assembly at all, and should not try — that is exactly the territory
where view() or an observer belongs.
The full list of seams and their exact semantics is in the documentation; getting the module into a page you already own, with the layering and scroll-height contract it expects, is the embedding guide. And because everything on this page moves type in front of a reader, it is worth reading alongside the accessibility guide, which covers the motion floor, the fallback and the no-script state as a whole rather than one seam at a time.