CSS Scroll-Driven Animations

The first time I tried scroll-driven animations, I spent forty minutes wiring up an IntersectionObserver, a requestAnimationFrame loop, and a small state machine just to fade in a section header as it crossed the viewport. It worked. It also janked on every second scroll on my partner's older phone, and the code lived in a file I was already afraid to open. A week later I deleted the whole thing and replaced it with six lines of CSS using animation-timeline: view(). The page felt lighter. So did I.
This article walks you through building scroll-driven animations the native way, using the new scroll-timeline and view-timeline properties shipping in Chromium-based browsers, with a sane fallback path for everyone else. You will start from a small starter repo, write your first progress bar tied to document scroll, graduate to viewport-triggered reveals with view-timeline, coordinate multiple named timelines across a layout, tune animation-range until the easing feels right, wire up an @supports fallback plus a JS polyfill, and finish with a complete landing page that has parallax, staged reveals, and a sticky nav indicator that knows which section you are reading. The rule worth screenshotting: if the browser already knows where you are on the page, stop teaching it again in JavaScript.
This is written for front-end developers who are comfortable with modern CSS and have at least once cursed an IntersectionObserver callback. By the end you will be able to ship scroll-linked motion that degrades cleanly, costs almost nothing on the main thread, and reads like prose six months later when you come back to fix something else.
Step 1: Bootstrapping a Bun + TypeScript Starter with a Live Support Probe
Scroll-driven animations land on the browser as a thin sheet of new CSS — animation-timeline: scroll(), animation-timeline: view(), and the animation-range property — but they only render when the engine actually ships the feature. Before we build a single scroll-bound effect, we need a starter project that refuses to lie about what the user's browser can do.
This first step gives us exactly that foundation: a Bun + TypeScript repository with a static index.html, a runtime feature-detection module that calls CSS.supports() for each declaration we depend on, and a small badge rendered into the header that summarises the result. The probe is the safety net every later step will lean on when we decide whether to apply the real timeline or a fall-back.
Setup
Create the workspace skeleton at the root of the repo. The codebase is deliberately framework-free — we lean on Bun as both the test runner and the bundler, and on plain TypeScript modules with strict compiler flags.
codebase/
├── package.json
├── tsconfig.json
├── src/
│ ├── support.ts
│ ├── badge.ts
│ └── main.ts
├── public/
│ ├── index.html
│ └── styles.css
└── test/
├── support.test.ts
└── badge.test.ts
The package.json declares Bun-only scripts — bun test for the suite and bun build src/support.ts --outdir public --target browser for the eventual browser bundle. The dev dependencies are minimal: typescript ^5.4 and @types/bun. Nothing else is allowed in this step; new dependencies only land when a step's tests demand them.
{
"name": "css-scroll-driven-animations",
"type": "module",
"scripts": {
"test": "bun test",
"build": "bun build src/support.ts --outdir public --target browser"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.4.0"
}
}
The tsconfig.json turns on strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, and verbatimModuleSyntax — the same dial-everything-up posture we want every later step to inherit. Pulling those flags forward now means we never have to retro-fix type holes when the effects get more interesting.
Implementation
The whole step revolves around three modules. The first, src/support.ts, owns the probe. It exposes typed declarations for the three CSS rules we care about and a single detectScrollDrivenSupport function that returns a small struct of booleans.
export const SCROLL_TIMELINE_DECL = "animation-timeline: scroll()";
export const VIEW_TIMELINE_DECL = "animation-timeline: view()";
export const ANIMATION_RANGE_DECL = "animation-range: entry 0% exit 100%";
export function detectScrollDrivenSupport(
cssApi?: CSSSupportsLike,
): ScrollDrivenSupport {
const api = resolveCss(cssApi);
if (!api || typeof api.supports !== "function") {
return { ...EMPTY_SUPPORT };
}
return {
scrollTimeline: safeSupports(api, SCROLL_TIMELINE_DECL),
viewTimeline: safeSupports(api, VIEW_TIMELINE_DECL),
animationRange: safeSupports(api, ANIMATION_RANGE_DECL),
};
}
Two design choices matter here. First, the function accepts an optional CSSSupportsLike so tests can inject a stub instead of monkey-patching the global CSS object. Second, the helper safeSupports wraps each call in a try/catch because some engines throw SyntaxError on declarations they do not recognise — silently treating that as "not supported" is the right behaviour for a feature probe.
The next module, src/badge.ts, is the seam between the probe and the DOM. It renders the result into three elements marked with data- hooks in the HTML — root, status, and details — and writes the overall verdict onto dataset.support so the stylesheet can colour the badge without any JavaScript reading the class list.
export function renderSupportBadge(
elements: BadgeElements,
support: ScrollDrivenSupport,
): void {
setBadgeState(elements, hasFullScrollDrivenSupport(support));
elements.details.textContent = summarizeSupport(support);
}
Splitting renderSupportBadge from mountSupportBadge keeps the unit tests pure: renderSupportBadge takes plain element references, so we can hand it fake objects with textContent and dataset and assert what it wrote. mountSupportBadge is the only function that touches the real document, and it short-circuits if the expected data-* nodes are missing.
The third module, src/main.ts, is the bootstrap. It waits for DOMContentLoaded if the parser is still working, then calls mountSupportBadge(document) exactly once. It is the only file that runs on import, which keeps the rest of the codebase tree-shakeable.
The static side mirrors the JS contract. public/index.html reserves a <section class="support-badge" data-support-badge data-support="unknown"> containing a <strong data-support-status> and a <span data-support-details>. public/styles.css styles the badge with color-mix(in srgb, var(--ok) 60%, transparent) borders driven by [data-support="ok"] and [data-support="missing"] selectors, so the verdict is visible at a glance.
Verification
Run the Bun suite from the codebase/ root. Both test files — one for the probe, one for the badge renderer — must pass before we move on.
bun test
bun test v1.2.19 (aad3abea)
10 pass
0 fail
14 expect() calls
Ran 10 tests across 2 files. [21.00ms]
The ten assertions cover the cases that bite real users: a missing global CSS object, an engine that throws on unknown declarations, partial support where only one of the three declarations is recognised, and the happy path where every declaration returns true. If any of those regress in a later step, this gate fails first.
What we built
We now have a starter repository that boots in any modern browser and immediately tells the visitor whether scroll-driven animations are available, without trying to render anything that would otherwise fail silently.
The probe is decoupled from the DOM, which is the invariant the rest of the tutorial depends on. Future steps will keep importing detectScrollDrivenSupport to decide whether to attach the real animation-timeline rules or to leave a static fall-back in place — and they will never have to know how CSS.supports() behaves on a given engine, because that knowledge is encapsulated here.
We also locked in the project's quality posture. Strict TypeScript, Bun as the single tool for tests and bundling, a framework-free public/ directory, and a renderer that takes element references rather than reaching into the DOM are all conventions every later step will inherit.
The next step builds on this baseline by introducing the first real scroll-driven effect — a progress bar bound to the document scroller — and uses the badge to decide whether to apply the timeline or to leave the bar at its starting position.
Repository
The state of the code after this step: 9f5335b
Step 2: Binding a Top-of-Viewport Progress Bar to the Document Scroll-Timeline
Step 1 left us with a starter that can answer one question: does this browser ship animation-timeline: scroll()? On its own that answer does nothing visible — the badge in the header reports the verdict, but no element on the page actually moves with the scroll. Step 2 turns the verdict into behaviour by drawing a thin progress bar pinned to the top of the viewport and binding it to the document scroller through a real scroll-timeline.
The contract we want is dual. In a supporting engine, the CSS alone drives the fill — there is no JavaScript animation loop, no requestAnimationFrame, just a declarative keyframe whose timeline is the root scroller. In an older engine, the same --scroll-progress custom property is written from a passive scroll listener, so the visual outcome looks identical even though the path is different.
Setup
No new dependencies. We add one module and one test file inside codebase/, and we extend the static assets in public/ so the bar has a place to live.
codebase/
├── src/
│ ├── progress.ts # new — pure progress math + mount function
│ └── main.ts # updated — boots the progress bar after the badge
├── public/
│ ├── index.html # updated — adds the progress-bar element + filler
│ └── styles.css # updated — keyframes, scroll() binding, fallback
└── test/
└── progress.test.ts # new — covers math, clamp, and mount branches
Keeping the scroll math in its own module — and making it accept plain objects through WindowLike and DocumentLike — is the same shape we used for the support probe. Headless tests stay headless, and the only file allowed to touch the real document is main.ts.
Implementation
The heart of the module is two tiny pure functions. computeScrollProgress divides the current scrollY by the maximum scrollable distance and clamps the result into [0, 1]. readScrollProgress is a thin convenience wrapper that does the scrollHeight - viewportHeight subtraction so callers can hand it a snapshot instead of computing the bound themselves.
export function computeScrollProgress(scrollY: number, maxScroll: number): number {
if (maxScroll <= 0) {
return 0;
}
return clampUnit(scrollY / maxScroll);
}
export function readScrollProgress(snapshot: ScrollSnapshot): number {
const maxScroll = snapshot.scrollHeight - snapshot.viewportHeight;
return computeScrollProgress(snapshot.scrollY, maxScroll);
}
Two edge cases drove the shape. A non-scrollable page reports a max scroll of zero or less, which would otherwise divide-by-zero or return a negative ratio — we treat those as 0 progress. Overscroll on either end (rubber-banding on macOS, pull-to-refresh on mobile) sends scrollY outside the valid range, so the clampUnit helper saturates at the boundaries before anyone reads it.
Writing the result to the DOM is its own function so it can be unit-tested without a real HTMLElement. applyProgressToElement accepts a ProgressTarget — an object with dataset and a style.setProperty method — clamps once more for paranoia, rounds the percentage for the data-progress attribute, and writes a four-decimal float into the --scroll-progress custom property.
export function applyProgressToElement(
element: ProgressTarget,
progress: number,
): void {
const clamped = clampUnit(progress);
element.dataset.progress = Math.round(clamped * 100).toString();
element.style.setProperty(PROGRESS_CSS_VAR, clamped.toFixed(4));
}
The branch that wires everything together is mountProgressBar. It checks the hasScrollTimeline flag the support probe handed us. When the engine ships animation-timeline: scroll(), the function tags the element with data-mode="css" and returns — no listener, no event traffic, nothing in the way of the browser's own compositor.
export function mountProgressBar(options: ProgressMountOptions): void {
const { target, win, doc, hasScrollTimeline } = options;
if (hasScrollTimeline) {
target.dataset.mode = "css";
return;
}
target.dataset.mode = "js";
const update = (): void => {
const progress = readScrollProgress({
scrollY: win.scrollY,
scrollHeight: doc.documentElement.scrollHeight,
viewportHeight: win.innerHeight,
});
applyProgressToElement(target, progress);
};
update();
win.addEventListener("scroll", update, { passive: true });
win.addEventListener("resize", update, { passive: true });
}
When the flag is false we run the fallback. An immediate update() writes the initial value so the bar is correct even before the first scroll event, and then scroll and resize are attached as passive listeners. resize matters because the document height can change as fonts load or images decode — without it, the ratio would drift the moment the page settles.
The CSS half is intentionally short. A @keyframes scroll-progress-grow rule grows the fill's scaleX from 0 to 1. Inside @supports (animation-timeline: scroll()), we attach that animation to elements with data-mode="css" and set animation-timeline: scroll(root block). Outside the @supports block, the fill reads transform: scaleX(calc(var(--scroll-progress) * 1)), which is the path the JS fallback drives.
Verification
Run the suite from codebase/. The new file adds thirteen assertions on top of step 1's ten, and every branch — math, clamp, DOM writes, and both mount modes — has its own test.
bun test
bun test v1.2.19 (aad3abea)
23 pass
0 fail
34 expect() calls
Ran 23 tests across 3 files. [26.00ms]
The mount tests are the ones worth calling out. The CSS-mode test fires a synthetic scroll event and asserts that data-progress was never written — proof that we did not silently attach a listener in the supporting path. The JS-mode test sets a fake scrollY, fires scroll, asserts the percentage, then changes the height and fires resize to confirm the recalc fires there too.
What we built
We now have a real scroll-driven effect rendered by the browser, not by JavaScript, on any engine that ships animation-timeline: scroll(). The bar at the top of the viewport tracks the document scroller in lockstep with the user's gesture, and the only code that runs on those browsers is the declarative CSS rule.
On engines that do not yet ship the feature, the same --scroll-progress custom property is written by a passive listener, and the same transform: scaleX(calc(var(--scroll-progress) * 1)) rule paints the fill. Readers cannot tell which path is active without inspecting the data-mode attribute — which is exactly the goal of progressive enhancement.
The pure-function shape of the progress math is the invariant the rest of the tutorial will lean on. Every later step that needs "how far along is the scroller?" — a parallax band, a sticky chapter marker, a number that counts up — will import computeScrollProgress and readScrollProgress instead of writing its own math.
The next step takes the same probe-or-fall-back pattern and points it at a per-element timeline: animation-timeline: view(), which fires as a specific element scrolls through the viewport rather than as the document scrolls overall.
Repository
The state of the code after this step: 03c1fde
Step 3: Driving Per-Element Reveals with animation-timeline: view()
Step 2 left the demo with a single document-level effect: a thin progress bar bound to the root scroller. That timeline answers "how far has the page scrolled?" but it cannot answer "how far has this card entered the viewport?" — and the second question is the one every reveal animation, sticky chapter marker, and parallax band wants to ask.
Step 3 adds the second pillar of CSS scroll-driven animations: animation-timeline: view(). Each card on the page now binds its own keyframed reveal to its own intersection with the scrollport, and the same probe-or-fall-back pattern from step 2 keeps the visual contract identical on engines that have not yet shipped the feature.
Setup
No new dependencies. We add one module and one test file, extend the existing static assets with reveal cards, and teach main.ts to mount the new effect.
codebase/
├── src/
│ ├── reveal.ts # new — entry-range math + mount function
│ └── main.ts # updated — boots reveals after the progress bar
├── public/
│ ├── index.html # updated — adds five [data-reveal] cards
│ └── styles.css # updated — view() binding + JS-mode fallback
└── test/
└── reveal.test.ts # new — covers math, clamp, refresh, and both mount modes
The shape mirrors progress.ts: pure functions take rectTop, rectHeight, and viewportHeight so the math can be exercised headlessly, and the only impurity — the mountReveal wiring — accepts a WindowLike so tests can fire synthetic events without a real browser.
Implementation
The math sits in one function. computeViewEntryProgress measures how far a single element has travelled through its entry range, the slice that begins the moment the element's leading edge meets the viewport's trailing edge and ends when its own trailing edge has fully entered. The raw expression (viewportHeight - rectTop) / rectHeight falls out of that definition directly.
export function computeViewEntryProgress(
rectTop: number,
rectHeight: number,
viewportHeight: number,
): number {
if (rectHeight <= 0 || viewportHeight <= 0) {
return 0;
}
const raw = (viewportHeight - rectTop) / rectHeight;
return clampUnit(raw);
}
Two degenerate cases drove the guard at the top. A zero-height element would divide by zero, and a zero-height viewport would produce a meaningless ratio — both collapse to 0 rather than NaN. The clamp at the bottom saturates the ratio at the boundaries so a card sitting well above the viewport reads 1, and one still well below reads 0, without leaking out-of-range values into the DOM.
Writing the result into the DOM is its own function, applyRevealProgress, modelled after applyProgressToElement from step 2. It accepts a RevealTarget shaped just enough to be faked in tests — a dataset bag and a style.setProperty method — and writes both a rounded data-reveal-progress percentage and a four-decimal --reveal-progress custom property.
export function applyRevealProgress(target: RevealTarget, progress: number): void {
const clamped = clampUnit(progress);
target.dataset.revealProgress = Math.round(clamped * 100).toString();
target.style.setProperty(REVEAL_CSS_VAR, clamped.toFixed(4));
}
The integer attribute is the affordance for DevTools and integration tests — you can read "this card is 73 % entered" at a glance. The float in the custom property is what the CSS actually reads, and the four-decimal width avoids visible step quantisation when the fallback path drives a transform that has to remain smooth at 60 Hz.
The mount function is the branch that picks the path. When the engine reports animation-timeline: view(), we tag every item with data-reveal-mode="css" and return without attaching any listeners. When it does not, we tag them data-reveal-mode="js", run a single update to write the initial values, and attach passive scroll and resize handlers that refresh every item from its current getBoundingClientRect().
export function mountReveal(options: RevealMountOptions): void {
const { items, win, hasViewTimeline } = options;
if (items.length === 0) {
return;
}
if (hasViewTimeline) {
markMode(items, "css");
return;
}
markMode(items, "js");
const update = (): void => refreshAll(items, win.innerHeight);
update();
win.addEventListener("scroll", update, { passive: true });
win.addEventListener("resize", update, { passive: true });
}
The empty-list early return is not paranoia — it is the contract that lets main.ts call mountReveal unconditionally on pages that happen to have no [data-reveal] elements, without paying for a scroll listener that would do nothing. The mode attribute is the same data-mode pattern step 2 used for the progress bar, so the stylesheet selectors stay symmetrical across both effects.
The CSS half is short and structured around two data-reveal-mode branches. The JS-mode rule reads the custom property directly into opacity and a small translateY, so the fallback path renders the same fade-and-rise the supporting engine renders declaratively. Inside @supports (animation-timeline: view()), we bind a reveal-rise keyframe to elements with data-reveal-mode="css" and pin animation-range: entry 0% entry 100% so the animation finishes exactly as the entry range ends.
@keyframes reveal-rise {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}
[data-reveal][data-reveal-mode="js"] {
opacity: calc(var(--reveal-progress, 0));
transform: translateY(calc((1 - var(--reveal-progress, 0)) * 2rem));
will-change: opacity, transform;
}
@supports (animation-timeline: view()) {
[data-reveal][data-reveal-mode="css"] {
animation: reveal-rise linear forwards;
animation-timeline: view();
animation-range: entry 0% entry 100%;
will-change: opacity, transform;
}
}
The will-change hint sits on both branches so the compositor promotes the card to its own layer in either mode. The entry 0% entry 100% range is the conservative default — the reveal completes as the card finishes entering view, and a reader scrolling back up watches the timeline scrub backwards for free, because a view-timeline is a function of position, not a one-shot trigger.
Verification
Run the suite from codebase/. The new file adds nineteen assertions on top of step 2's twenty-three; every branch — math, clamp, DOM writes, refresh, and both mount modes — owns at least one test.
bun test
bun test v1.2.19 (aad3abea)
42 pass
0 fail
65 expect() calls
Ran 42 tests across 4 files. [33.00ms]
The two mountReveal tests are the ones worth calling out. The CSS-mode test fires synthetic scroll and resize events and asserts that data-reveal-progress was never written — proof that the supporting path takes zero event traffic. The JS-mode test sets a fake rect, fires scroll, asserts a fifty-percent reading, then changes innerHeight and the rect again and fires resize to confirm the recalc fires on layout changes too.
What we built
We now have a per-element scroll-driven effect that the browser renders declaratively in supporting engines. Each card on the page binds its own animation-timeline: view() to its own intersection with the scrollport, and the compositor scrubs the fade-and-rise in lockstep with the user's gesture — no IntersectionObserver, no requestAnimationFrame, no listener at all on the hot path.
The same --reveal-progress custom property is written from a passive scroll listener on engines that have not shipped view() yet. The two paths share a single keyframe shape and a single mode attribute, so the only difference a reader can detect is the value of data-reveal-mode in DevTools — which is exactly the progressive-enhancement guarantee we set up in step 1.
The pure-function math is the invariant later steps will lean on. Step 4 will need to coordinate multiple animations across a single named timeline, and the entry-range expression we introduced here is the building block every later effect — sticky chapter markers, parallax bands, count-up numbers — will reuse.
The next step replaces the implicit anonymous view() with a named timeline declared on one element and consumed by several, so a single source of progress can drive a whole layout's worth of synchronised animations.
Repository
The state of the code after this step: b3436da
Step 4: Synchronising a Sticky Rail and Caption with a Single Named view-timeline
Step 3 left every reveal card carrying its own anonymous animation-timeline: view(). That works when each element animates only itself, but it breaks the moment two elements need to share one source of progress — a sticky chapter rail rising in time with the article body, or a caption brightening as the article enters the scrollport. Anonymous view() is implicitly self; a sticky sibling cannot reach into it.
Step 4 introduces named view-timelines. One article element declares view-timeline-name: --chapter, the surrounding band hoists it with timeline-scope, and the rail plus caption bind their own keyframes to animation-timeline: --chapter. The JavaScript fallback mirrors the broadcast with a single --chapter-progress custom property written once per band and inherited by every consumer.
Setup
No new dependencies. We add one TypeScript module, one test file, and grow the static demo with a .chapter-group band wired for named timelines.
codebase/
├── src/
│ ├── chapter.ts # new — named-timeline group model + mount
│ └── main.ts # updated — boots chapters after reveals
├── public/
│ ├── index.html # updated — adds a [data-chapter-group] band
│ └── styles.css # updated — timeline-scope + view-timeline-name + fallbacks
└── test/
└── chapter.test.ts # new — covers group writes, refresh, and both mount modes
The module reuses computeViewEntryProgress from reveal.ts so the entry-range math has exactly one home. The group model is the new piece: a ChapterGroup bundles a root that owns the custom property, a source whose rect drives progress, and a list of consumers that only need a mode flag to pick the right CSS branch.
Implementation
The smallest function does the writing. applyChapterProgress clamps the value, stores a rounded integer percentage on the group root for DevTools, and writes a four-decimal float into --chapter-progress for the cascade to inherit. The custom property lives on the root only — every consumer reads it through inheritance, so a single write fans out to the whole band.
export function applyChapterProgress(root: ChapterRoot, progress: number): void {
const clamped = clampUnit(progress);
root.dataset.chapterProgress = Math.round(clamped * 100).toString();
root.style.setProperty(CHAPTER_CSS_VAR, clamped.toFixed(4));
}
refreshChapter is the per-tick worker. It reads the source rect, asks the shared entry-range function for a progress value, and hands it to applyChapterProgress. Crucially, it never touches consumer datasets — that is the invariant the tests pin down, because every extra DOM write on a scroll handler shows up in a flame graph.
export function refreshChapter(group: ChapterGroup, viewportHeight: number): void {
const rect = group.source.getBoundingClientRect();
const progress = computeViewEntryProgress(rect.top, rect.height, viewportHeight);
applyChapterProgress(group.root, progress);
}
mountChapters is the branch that picks the path. On engines that ship named view-timelines it tags the root plus every consumer with data-chapter-mode="css" and returns immediately — the CSS declarations carry the whole effect with zero listeners. On engines without support it tags everything "js", runs one initial refresh per group, and attaches passive scroll and resize handlers that loop over refreshChapter for each band.
export function mountChapters(options: ChapterMountOptions): void {
const { groups, win, hasViewTimeline } = options;
if (groups.length === 0) {
return;
}
if (hasViewTimeline) {
markAll(groups, "css");
return;
}
markAll(groups, "js");
const update = (): void => refreshAll(groups, win.innerHeight);
update();
win.addEventListener("scroll", update, { passive: true });
win.addEventListener("resize", update, { passive: true });
}
The empty-list early return keeps main.ts honest: it can boot chapters unconditionally on pages without a single [data-chapter-group] and pay nothing. The single mode attribute on the root and every consumer is the small redundancy that lets sticky descendants outside the root selector still pick the right @supports branch.
The CSS half is where the named timeline actually lives. The band declares timeline-scope: --chapter so the name created inside it is visible to its descendants; the inner article publishes that name with view-timeline-name: --chapter and a block axis. Both the rail and the caption then bind animation-timeline: --chapter to their own keyframes, so one rect drives multiple animations in lockstep.
.chapter-group {
timeline-scope: --chapter;
--chapter-progress: 0;
}
.chapter {
view-timeline-name: --chapter;
view-timeline-axis: block;
}
@supports (animation-timeline: --chapter) {
[data-chapter-group][data-chapter-mode="css"] .chapter-rail {
animation: chapter-rail-rise linear forwards;
animation-timeline: --chapter;
animation-range: entry 0% entry 100%;
}
[data-chapter-group][data-chapter-mode="css"] .chapter-caption {
animation: chapter-caption-glow linear forwards;
animation-timeline: --chapter;
animation-range: entry 0% contain 50%;
}
}
Two consumers, two different ranges, one source. The rail finishes its rise as the article finishes entering; the caption keeps glowing a little longer into the contain range, because a brightening caption looks unfinished if it snaps off the moment entry ends. The JS-mode rules read var(--chapter-progress) the same way step 3's fallback read --reveal-progress, so the visual contract holds even when the engine has not yet shipped named timelines.
Verification
Run the suite from codebase/. The new file adds five assertions across three describe blocks; the most interesting one fires synthetic events to prove the CSS branch writes nothing while the JS branch fans out the correct value to every group.
bun test
bun test v1.2.19 (aad3abea)
47 pass
0 fail
82 expect() calls
Ran 47 tests across 5 files. [157.00ms]
The decisive test mounts two bands with hasViewTimeline: false, moves the first source rect, fires scroll, and asserts the first band reads 50 while the second still reads 0. It then bumps innerHeight, moves the second source, fires resize, and asserts the second band catches up. That sequence proves both that the loop iterates per group and that recalc fires on viewport changes, not just scroll.
What we built
We now have a single source of view-progress driving two visually distinct animations on different elements. The article publishes --chapter once; the rail consumes it through one keyframe with one range, the caption consumes it through a different keyframe with a different range, and the cascade keeps them locked together with no JavaScript on the hot path.
The JS fallback writes one custom property on the band root per refresh and lets inheritance fan it out to every descendant that reads var(--chapter-progress). That single-write/many-readers shape is the runtime mirror of what view-timeline-name does declaratively, so the two paths have matching cost profiles.
The new invariant — exactly one write per group per tick, regardless of consumer count — is the property the test suite pins down. It is also the property that makes named timelines safe to scale: adding a third consumer is a CSS-only change with zero new event traffic on either path.
Step 5 builds on this by tightening animation-range itself. We will lean on the same group model to split a single named timeline into multiple non-overlapping ranges — entry, contain, and exit — and animate different consumers across different slices of the same source.
Repository
The state of the code after this step: ac60aa7
Step 5: Slicing a Single Timeline into Entry, Contain, and Exit Ranges
Step 4 broadcast one named --chapter timeline to a rail and a caption, but both consumers shared the same entry slice and only diverged at a single trailing percent. The interesting thing animation-range can do is the opposite: take one source rect and bind three different consumers to three non-overlapping slices of it, so an icon, a highlight, and a trail each animate exactly while its phase is active. This step builds a "phase strip" band that proves the slicing math holds whether the engine ships scroll-driven animations or falls back to JavaScript.
The new code is two pure modules. range.ts is the math layer that maps a (phase, percent) pair to a cover-progress fraction, and phaseStrip.ts is the orchestration layer that writes one custom property per phase onto a single band root. The CSS branch and the JS branch both read those properties; the only difference is who computes them on a given tick.
Setup
No new dependencies. We add two TypeScript modules, two test files, and a [data-phase-strip-group] band in the static demo with three consumers and three keyframes.
codebase/
├── src/
│ ├── range.ts # new — pure phase / percent math
│ ├── phaseStrip.ts # new — group model + mount, one write per range
│ └── main.ts # updated — boots phase strips alongside chapters
├── public/
│ ├── index.html # updated — adds a [data-phase-strip-group] band
│ └── styles.css # updated — three keyframes, three animation-range slices, JS fallback
└── test/
├── range.test.ts # new — covers phaseBounds, rangePointToCover, computeRangeProgress
└── phaseStrip.test.ts # new — covers writes, group iteration, CSS/JS branch
range.ts intentionally exports zero DOM types — it speaks in numbers and string phase names so the math can be tested without any browser shim. phaseStrip.ts owns the only DOM surface; its group model carries a list of NamedRange objects so the consumer count and slice count stay decoupled. The mount function reuses the WindowLike interface from step 2's progress module, so the listener wiring is identical to what we already trust.
Implementation
The phase math has one trick: the "small" and "large" sides of the cover span swap depending on whether the element is shorter or taller than the viewport, but the band order always stays entry → contain → exit. phaseBounds folds that into a single min/max so the same formula serves both regimes without an if rectHeight > viewportHeight branch.
export function phaseBounds(
phase: PhaseName,
rectHeight: number,
viewportHeight: number,
): PhaseBounds {
if (rectHeight <= 0 || viewportHeight <= 0) {
return { ...EMPTY_BOUNDS };
}
const span = viewportHeight + rectHeight;
const small = smallSide(rectHeight, viewportHeight) / span;
const large = largeSide(rectHeight, viewportHeight) / span;
if (phase === "cover") {
return { start: 0, end: 1 };
}
if (phase === "entry") {
return { start: 0, end: small };
}
if (phase === "contain") {
return { start: small, end: large };
}
return { start: large, end: 1 };
}
rangePointToCover then maps a (phase, percent) point into a cover fraction, and computeRangeProgress clamps the cover value into the window's [start, end] slice and remaps it to [0, 1]. The function deliberately collapses to a step function when the window has zero length — that mirrors what the CSS spec does for animation-range: contain 50% contain 50%, and the test suite pins the behaviour down.
export function computeRangeProgress(
coverProgress: number,
window: RangeWindow,
rectHeight: number,
viewportHeight: number,
): number {
const start = rangePointToCover(window.start, rectHeight, viewportHeight);
const end = rangePointToCover(window.end, rectHeight, viewportHeight);
if (end <= start) {
return coverProgress >= start ? 1 : 0;
}
const raw = (coverProgress - start) / (end - start);
return clampUnit(raw);
}
phaseStrip.ts wraps a group with a list of ranges and writes one custom property per range, deriving each name from the range's own name field (--phase-entry, --phase-contain, --phase-exit). A shared --phase-cover is also written for downstream tooling that wants the raw cover reading. The entire hot path is rect → cover → loop over ranges → setProperty; consumer datasets are never touched on a tick, which keeps the JS branch flame-graph-friendly.
export function refreshPhaseStrip(
group: PhaseStripGroup,
viewportHeight: number,
): void {
const rect = group.source.getBoundingClientRect();
const cover = computeCoverProgress(rect.top, rect.height, viewportHeight);
applyCoverProgress(group.root, cover);
for (const range of group.ranges) {
const value = computeRangeProgress(
cover,
range.window,
rect.height,
viewportHeight,
);
applyRangeProgress(group.root, range, value);
}
}
mountPhaseStrips picks the branch with the same shape as mountChapters: when view() is supported, tag everyone "css" and return; otherwise tag everyone "js", run one initial refresh, and attach passive scroll and resize listeners that loop refreshPhaseStrip over every band. The two modes are interchangeable from the cascade's point of view because the CSS variables they ultimately drive are the same.
The CSS half is where the slicing actually lives. The band declares timeline-scope: --phase-strip, the inner article publishes view-timeline-name: --phase-strip, and three consumers each bind a distinct animation-range window to that single source.
@supports (animation-timeline: --phase-strip) {
[data-phase-strip-group][data-phase-strip-mode="css"] .phase-strip__icon {
animation: phase-icon-enter linear forwards;
animation-timeline: --phase-strip;
animation-range: entry 0% entry 100%;
}
[data-phase-strip-group][data-phase-strip-mode="css"] .phase-strip__highlight {
animation: phase-highlight-hold linear forwards;
animation-timeline: --phase-strip;
animation-range: contain 0% contain 100%;
}
[data-phase-strip-group][data-phase-strip-mode="css"] .phase-strip__trail {
animation: phase-trail-exit linear forwards;
animation-timeline: --phase-strip;
animation-range: exit 0% exit 100%;
}
}
Three keyframes, three ranges, one source. The icon rises only while the band is entering, the highlight holds only while it is fully contained, and the trail fades only while it is exiting. The JS-mode rules read var(--phase-entry), var(--phase-contain), and var(--phase-exit) instead, but each variable is derived from the same cover value, so the choreography is bit-for-bit identical across the two paths.
Verification
Run the suite from codebase/. The two new files add 36 assertions across eight describe blocks; the most demanding test pushes the source rect to the contain band and asserts that --phase-entry is already 1.0000 while --phase-exit is still 0.0000, proving the slicing math isolates each phase.
bun test
bun test v1.2.19 (aad3abea)
83 pass
0 fail
140 expect() calls
Ran 83 tests across 7 files. [267.00ms]
The phaseBounds block cross-checks the tall-element regime (rect taller than viewport) against the short-element regime, confirming the entry/contain/exit ordering never inverts even when small and large swap roles. The cross-phase test in computeRangeProgress feeds an entry 0% → contain 50% window and verifies the result interpolates linearly across the phase boundary, which is the case that breaks any naive per-phase clamp.
What we built
We now own a phase strip: one named view-timeline drives three independent animations, each bound to a single non-overlapping animation-range slice. An icon plays only across entry, a highlight only across contain, and a trail only across exit, and the choreography survives whether the engine ships scroll-driven animations or falls back to the JavaScript writer.
The pure range.ts module is the part most likely to outlive this article. It speaks in (phase, percent) pairs that map one-to-one onto the CSS animation-range tokens, so the same window descriptor can be both formatted into a CSS string with formatRangeWindow and computed against a runtime cover value with computeRangeProgress. Anywhere we need to mirror the view-timeline contract from JS, we now have a shared vocabulary.
phaseStrip.ts adds the invariant the test suite pins down: one write per range per group per tick, regardless of how many consumers read it. Each consumer is a CSS rule rather than a JS listener, so adding a fourth slice — say a contain 50% exit 25% overlap for a held-then-drift effect — is a stylesheet edit plus one entry in PHASE_STRIP_RANGES, with zero new event traffic on either path.
Step 6 will fold this into a richer page-level composition, combining the document scroll-timeline from step 2 with the chapter and phase-strip view-timelines, so a single scroll gesture drives effects at three different time granularities at once.
Repository
The state of the code after this step: 628fd42
Step 6: A Per-Consumer Animation Policy Gated by Support, Reduced Motion, and a Lazy Polyfill
Steps 1 through 5 each hand-wired their own fallback decision. The progress bar asked hasScrollTimeline, the reveal and chapter modules asked hasViewTimeline, and the phase strip cross-checked viewTimeline && animationRange. The CSS branches were already wrapped in @supports blocks, but the JS bootstrap had four independent feature checks and zero awareness of the user's motion preference — a reader who sets prefers-reduced-motion: reduce would still see the same animations.
This step closes that gap. We collapse the four ad-hoc checks into one decideAnimationPolicy function that returns a per-consumer mode ("css" | "js" | "off"), publish the decision onto documentElement.dataset so the stylesheet can opt out at the cascade level, and add a lazy polyfill loader that only attaches scroll-timeline-polyfill when the engine is missing a feature. The fast path stays zero-cost on modern browsers because the polyfill bytes never enter the network in the first place.
Setup
No runtime dependencies are added — the polyfill loader operates on whatever <script> URL the host page configures, defaulting to the upstream scroll-timeline-polyfill CDN build. Two new TypeScript modules, two test files, and small edits to main.ts, index.html, and styles.css complete the step.
codebase/
├── src/
│ ├── policy.ts # new — pure decision: per-consumer css/js/off
│ ├── polyfill.ts # new — lazy script-tag loader for the upstream polyfill
│ └── main.ts # updated — boots every consumer off the policy result
├── public/
│ ├── index.html # updated — adds a [data-policy-summary] sink
│ └── styles.css # updated — reduced-motion opt-out keyed off the policy dataset
└── test/
├── policy.test.ts # new — 15 assertions across four describes
└── polyfill.test.ts # new — 6 assertions covering load, error, and idempotency
policy.ts exports zero DOM types — it speaks only in ScrollDrivenSupport and ReducedMotion values, so the decision logic is deterministic and testable without a browser shim. polyfill.ts defines its own narrow PolyfillDocument interface around createElement plus head.appendChild, which lets the test suite hand in a plain object that records every appended script.
Implementation
The decision function takes one struct and returns one struct. The interesting shape is that each consumer has its own column in the result, because the support requirements are not the same: the progress bar needs scroll(), reveal and chapter need view(), and the phase strip from step 5 additionally needs animation-range. Encoding that asymmetry in the type means the cascade-side code can opt each consumer in or out without re-deriving the support implications.
export function decideAnimationPolicy(input: PolicyInput): AnimationPolicy {
const { support, reducedMotion } = input;
return {
progressBar: scrollBoundMode(support.scrollTimeline, reducedMotion),
reveal: scrollBoundMode(support.viewTimeline, reducedMotion),
chapter: scrollBoundMode(support.viewTimeline, reducedMotion),
phaseStrip: phaseStripMode(support, reducedMotion),
};
}
scrollBoundMode is the common case — if reduced motion is requested the consumer is "off", otherwise "css" when the relevant timeline declaration is supported and "js" when it isn't. phaseStripMode is the same shape with the extra animation-range constraint, deliberately split into its own helper so the two-level if reads cleanly. There is no third branch nested inside either function, which keeps the file inside the project's two-level nesting cap.
export function readReducedMotion(matchMedia?: MatchMediaLike): ReducedMotion {
if (typeof matchMedia !== "function") {
return "unknown";
}
try {
return matchMedia(REDUCED_MOTION_QUERY).matches ? "reduce" : "no-preference";
} catch {
return "unknown";
}
}
The matchMedia probe mirrors the safety posture of detectScrollDrivenSupport from step 1: the function accepts an optional MatchMediaLike, treats a throwing engine as "unknown", and never assumes a global. The policy routes "unknown" as "no-preference" — the conservative choice for a feature we cannot measure is to let the animations play, because forcing them off on an unknown engine would silently disable the entire tutorial demo.
The polyfill loader is also a single function returning a promise. It creates one <script> tag, sets async, tags it with data-polyfill="scroll-timeline" so a later boot can detect that the loader has already fired, and resolves when onload lands. The loader is a pure progressive enhancement — it never runs at all when both scrollTimeline and viewTimeline already report true.
export function loadScrollTimelinePolyfill(
options: PolyfillLoadOptions,
): Promise<PolyfillScriptElement> {
const { doc } = options;
const src = options.src ?? DEFAULT_POLYFILL_SRC;
return new Promise((resolve, reject) => {
const script = doc.createElement("script");
configureScript(script, src);
script.onload = (): void => resolve(script);
script.onerror = (): void => reject(new Error(`Failed to load ${src}`));
doc.head.appendChild(script);
});
}
applyPolyfillBoost is the bridge between the two modules. Once the polyfill resolves, every feature in the support struct is forced true so the second pass through decideAnimationPolicy routes every consumer to "css". The function explicitly returns a copy rather than mutating its input, which is the invariant the test suite pins down.
main.ts now reads support once, computes the policy once, and publishes the result onto document.documentElement.dataset before any consumer boots. Each boot* helper accepts the mode for its slot and short-circuits when it is "off", so the four mounts have identical shape and the reduced-motion path is a single early return instead of four separate conditional blocks.
onReady(document, () => {
mountSupportBadge(document);
const rawSupport = detectScrollDrivenSupport();
const support = applyPolyfillBoost(rawSupport, false);
const reducedMotion = readReducedMotion(
typeof window.matchMedia === "function"
? window.matchMedia.bind(window)
: undefined,
);
const policy = decideAnimationPolicy({ support, reducedMotion });
publishPolicy(document, policy);
bootProgressBar(document, window, policy.progressBar);
bootReveal(document, window, policy.reveal);
bootChapters(document, window, policy.chapter);
bootPhaseStrips(document, window, policy.phaseStrip);
});
The stylesheet picks up the policy through a prefers-reduced-motion media block keyed off :root[data-policy-* = "off"]. The selector list disables the moving parts of every consumer with animation: none !important and transform: none !important, then re-pins the progress bar at scaleX(1) so the static fallback still communicates "you have scrolled the page" rather than collapsing to a hairline.
@media (prefers-reduced-motion: reduce) {
:root[data-policy-progress="off"] .scroll-progress__fill,
:root[data-policy-reveal="off"] [data-reveal],
:root[data-policy-chapter="off"] [data-chapter-group] .chapter-rail,
:root[data-policy-chapter="off"] [data-chapter-group] .chapter-caption,
:root[data-policy-phase-strip="off"] [data-phase-strip-group] .phase-strip__icon,
:root[data-policy-phase-strip="off"] [data-phase-strip-group] .phase-strip__highlight,
:root[data-policy-phase-strip="off"] [data-phase-strip-group] .phase-strip__trail {
animation: none !important;
transform: none !important;
opacity: 1 !important;
transition: none !important;
}
}
Pairing the media query with the dataset attribute is intentional: the engine-level reduced-motion preference and the JS-level policy must both agree before the override fires. That keeps a misconfigured page from accidentally freezing animations the user actually wanted, and it keeps the cascade rule out of the matched-selector set entirely on the fast path.
Verification
Run the full suite from codebase/. The two new files add 21 assertions across seven describes, and every assertion from earlier steps continues to pass — the policy refactor is observable behaviour from main.ts's point of view but invisible to the per-module tests.
bun test
bun test v1.2.19 (aad3abea)
103 pass
0 fail
174 expect() calls
Ran 103 tests across 9 files. [1.55s]
The most load-bearing test in policy.test.ts is the cross-feature regression: with scrollTimeline: true, viewTimeline: false, animationRange: false, the policy pins the progress bar to "css" while routing reveal, chapter, and phase strip to "js". That asymmetry is the case earlier steps quietly assumed but never exercised in one place, and it is now the single source of truth for how the page degrades on a Firefox build that ships scroll() without view().
What we built
We now have one decision point for every fallback the page makes. decideAnimationPolicy reads support and motion preference, returns a per-consumer mode, and the four boots in main.ts follow the result without any of them re-deriving the rules.
The reduced-motion path is honoured at three layers — the JS skips the mount, the stylesheet skips the animation, and the progress bar pins to its terminal scaleX(1) so a user who opts out of motion still gets a meaningful static state. The three layers agree because they all read the same data-policy-* attributes published by publishPolicy.
The polyfill is opt-in by feature detection rather than by user agent string. shouldLoadPolyfill checks the support struct, the loader attaches one async script when needed, and applyPolyfillBoost lets a follow-up re-run of decideAnimationPolicy upgrade every consumer to "css" once the polyfill resolves. The fast path never ships the polyfill bytes, and the slow path swaps in CSS-driven animations without a per-module retrofit.
Step 7 will lean on this seam to add a small status panel that surfaces the live policy summary alongside the support badge, giving us a single on-page diagnostic for what each consumer is doing and why.
Repository
The state of the code after this step: e7cca72
Step 7: Composing the Showcase Page — Parallax Hero, View-Timeline Reveals, and a Sticky Section Indicator
Steps 1 through 6 each built one consumer in isolation — a progress bar, a reveal animation, a named chapter timeline, a phase strip, and a policy layer that decides whether each one runs as CSS, JS, or off. None of those previous demos shared a page; every step shipped a small example that exercised one feature on its own. The showcase build in this step is the first time we put them all on the same scroll surface and let them coexist.
We add two new consumers — a parallax hero that drifts depth layers as the viewport covers it, and a sticky right-rail nav indicator that interpolates its dot between section tops — then extend the animation policy and the boot sequence to schedule them alongside the existing four. The goal is a single index page that exercises every primitive (scroll(), view(), named timelines, animation-range, timeline-scope) end to end with zero IntersectionObserver and zero scroll listeners on the modern fast path.
Setup
Two new TypeScript modules, two test files, and edits to policy.ts, main.ts, index.html, and styles.css complete the step. No new runtime dependencies are added — the parallax module reuses computeCoverProgress from step 5, and the nav indicator depends only on getBoundingClientRect and the existing WindowLike shape.
codebase/
├── src/
│ ├── parallax.ts # new — cover-range JS fallback + dataset tagging
│ ├── navIndicator.ts # new — scan-line interpolation across section tops
│ ├── policy.ts # updated — parallax + navIndicator slots
│ └── main.ts # updated — bootParallax + bootNavIndicator
├── public/
│ ├── index.html # updated — hero parallax layers + sticky nav rail
│ └── styles.css # updated — CSS-driven parallax + indicator dot
└── test/
├── parallax.test.ts # new — 18 assertions across four describes
└── navIndicator.test.ts # new — 18 assertions across five describes
parallax.ts exports the layer-and-source group shape, the --parallax-progress / --parallax-offset CSS variable names, and a pure computeParallaxOffset(coverProgress, depth, travel) that the test suite drives without any DOM. navIndicator.ts exposes a scanRatio knob — the fraction of the viewport that defines the "active" Y position — so the showcase page can configure the scan line at 30% of viewport height through data-nav-scan-ratio="0.3" without code edits.
Implementation
The parallax module reads the cover-range progress of the hero section, then translates each depth layer by an offset proportional to its --parallax-depth custom property. The math is symmetric around the midpoint of the cover range — at coverProgress = 0 the layers sit +0.5 * depth * travel below their resting position, at coverProgress = 1 they sit -0.5 * depth * travel above it, and they cross zero exactly when the section is centred in the viewport.
export function computeParallaxOffset(
coverProgress: number,
depth: number,
travel: number,
): number {
const clamped = clampUnit(coverProgress);
if (!Number.isFinite(depth) || !Number.isFinite(travel)) {
return 0;
}
return (0.5 - clamped) * depth * travel;
}
The (0.5 - clamped) factor is the load-bearing piece. It guarantees the resting frame falls at the geometric centre of the cover range rather than at one of its endpoints, which is what gives the illusion of layers drifting past a stationary horizon as the user scrolls. The CSS fast path uses the same math through a @keyframes parallax-rise { from { translateY(3rem) } to { translateY(-3rem) } } rule bound to animation-range: cover 0% cover 100%, so the JS and CSS branches produce the same trajectory.
export function mountParallax(options: ParallaxMountOptions): void {
const { groups, win, hasViewTimeline } = options;
if (groups.length === 0) {
return;
}
if (hasViewTimeline) {
markAll(groups, "css");
return;
}
markAll(groups, "js");
const update = (): void => refreshAll(groups, win.innerHeight);
update();
win.addEventListener("scroll", update, { passive: true });
win.addEventListener("resize", update, { passive: true });
}
mountParallax short-circuits to a single markAll(groups, "css") call when the engine reports view-timeline support — once the dataset is tagged the cascade does the rest, and no listeners ever attach. The fallback branch is the only place where a scroll listener appears in this step, and it reuses the passive-listener posture from steps 2 and 3 so we never introduce a per-frame layout cost on the JS path either.
The nav indicator does not bind to a scroll-timeline at all — there is no animation-timeline: indicator() declaration in CSS — because the active section is a function of which data-nav-section element straddles the scan line, not a function of overall scroll progress. The compute is a small piecewise linear interpolator across section tops:
export function computeNavActivePos(
sectionTops: number[],
scanY: number,
): number {
if (sectionTops.length === 0) {
return 0;
}
const lastIndex = sectionTops.length - 1;
if (scanY <= sectionTops[0]!) {
return 0;
}
if (scanY >= sectionTops[lastIndex]!) {
return lastIndex;
}
return interpolateBetween(sectionTops, scanY, lastIndex);
}
The function returns a float — 1.5 means "halfway between section 1 and section 2" — and the consumer side has two helpers built on top: computeNavActiveIndex rounds the float for the data-nav-index attribute that drives the active-link colour, while --nav-active-pos stores the raw float for the dot's translateY. The dot therefore slides smoothly between rest positions while the link colour snaps, which is the visual idiom we want for a sticky rail.
export function applyNavActivePos(
root: NavRoot,
activePos: number,
count: number,
): void {
const safeCount = Math.max(0, count);
const lastIndex = Math.max(0, safeCount - 1);
const clampedPos = clampInRange(activePos, 0, lastIndex);
const index = computeNavActiveIndex(clampedPos);
root.dataset.navIndex = index.toString();
root.dataset.navCount = safeCount.toString();
root.style.setProperty(NAV_ACTIVE_POS_VAR, clampedPos.toFixed(4));
root.style.setProperty(NAV_SEGMENT_COUNT_VAR, safeCount.toString());
}
Publishing the segment count alongside the active position lets the stylesheet size the rail dynamically — min-height: calc(var(--nav-segment-count) * var(--nav-item-size)) — so adding or removing a section from the HTML never requires editing the CSS. That keeps the indicator declarative in the same sense as the other consumers: shape the DOM, the rest follows.
The policy.ts change is a two-line widening — the AnimationPolicy type gains parallax and navIndicator slots, and decideAnimationPolicy routes parallax through the same scrollBoundMode(viewTimeline, reducedMotion) helper as reveal and chapter while the nav indicator goes through its own navIndicatorMode because it is JS-only by design ("js" when motion is allowed, "off" when the user opts out). summarizePolicy gains the two new keys so the on-page diagnostic in the support badge keeps listing every consumer.
export function decideAnimationPolicy(input: PolicyInput): AnimationPolicy {
const { support, reducedMotion } = input;
return {
progressBar: scrollBoundMode(support.scrollTimeline, reducedMotion),
reveal: scrollBoundMode(support.viewTimeline, reducedMotion),
chapter: scrollBoundMode(support.viewTimeline, reducedMotion),
phaseStrip: phaseStripMode(support, reducedMotion),
parallax: scrollBoundMode(support.viewTimeline, reducedMotion),
navIndicator: navIndicatorMode(reducedMotion),
};
}
main.ts adds two new boot* functions that mirror the shape of the four existing ones — collect groups from the document, return early on "off", hand the mounted options to the module — and publishPolicy adds two new data-policy-* attributes so the reduced-motion stylesheet rule can opt the new consumers out at the cascade level. The index.html hero now declares data-parallax-group with three data-parallax-layer children at depths 0.25 / 0.6 / 1, and the fixed right rail uses data-nav-indicator data-nav-scan-ratio="0.3" with five data-nav-section anchors marking the section ranges.
Verification
Run the full suite from codebase/. Steps 1 through 6 contributed 103 assertions across nine files; this step adds 36 more assertions across two files (parallax + navIndicator) without touching the earlier ones, bringing the totals to 139 tests and 229 expectations.
bun test
bun test v1.2.19 (aad3abea)
139 pass
0 fail
229 expect() calls
Ran 139 tests across 11 files. [196.00ms]
The two regression cases that anchor the new behaviour are refreshNavIndicator "rereads section rects on each call so a moved section updates the active index" — which proves the indicator survives layout shifts that move section tops underneath it — and mountParallax "falls back to JS-driven cover progress when view() is missing" — which proves the JS path observes the same cover-range progress that the CSS path would otherwise consume.
What we built
The landing page now exercises six independent scroll-driven consumers in one document. A scroll-timeline drives the top progress bar, two view-timelines drive the reveal cards and the parallax hero, two named view-timelines (--chapter and --phase-strip) drive the chapter band and the phase strip respectively, and a single scroll-position scan line drives the sticky nav indicator. Every CSS-fast-path consumer attaches zero JavaScript listeners; the JS-fallback paths reuse the same custom properties so the visual contract is identical.
The parallax module is the first consumer in the project where the JS fallback uses a different transform than the CSS keyframe but agrees on the trajectory mid-way through the cover range. That asymmetry is deliberate: the JS path writes translateY(layerDepth * 6rem * offset) per layer where offset is (0.5 - cover), while the CSS path animates a single translateY(3rem) → translateY(-3rem) keyframe scaled by --parallax-depth. Both routes pass through translateY(0) at cover = 0.5, which is the only frame the user is likely to inspect long enough to notice.
The nav indicator deliberately stays JS-only because no scroll-driven primitive in the current draft expresses the operation we need — "which of N siblings is closest to a fixed viewport Y" is not a timeline mapping. We feed it through the same policy layer anyway so reduced-motion still disables the sliding dot, and we publish the segment count as a CSS variable so the rail height tracks the section count without any code change.
The composed page is also the first place the policy summary becomes useful as documentation rather than as a debug aid. A reader who lands on the page in a browser that ships scroll() without view() will see progress=css, reveal=js, chapter=js, phase-strip=js, parallax=js, nav-indicator=js printed under the support badge, and every consumer will degrade gracefully without per-element retrofits. That is the contract step 6 set up; this step is what it was set up for.
Repository
The state of the code after this step: 1730921
Repository
Full source at https://github.com/vytharion/css-scroll-driven-animations.
Walk the lessons by stepping through the git commits in the repo — each major step has its own commit you can git checkout and rerun.