Guide
CSS scroll-driven animations explained
Harbor Commerce's long-form buying guides — mattress comparisons,
appliance spec breakdowns, warranty explainers — shipped a reading
progress bar and staggered section fade-ins driven by a
scroll event listener and requestAnimationFrame.
The script measured scrollY, computed percentages against
document.documentElement.scrollHeight, updated a width style
on the progress element, and toggled opacity classes when
sections crossed a hard-coded pixel threshold. The bundle carried 120 lines
of scroll math that janked on mobile Safari, fought with nested scroll
containers, and ignored prefers-reduced-motion unless engineers
remembered a separate branch. Engineers replaced it with
CSS scroll-driven animations — a model where
keyframes advance in lockstep with scroll position through
scroll-timeline and view-timeline, without
JavaScript listeners. This guide explains animation-timeline,
named and anonymous timelines, animation-range entry/exit
windows, how scroll-driven motion pairs with
Intersection Observer
and
content-visibility,
walks a Harbor Commerce refactor, provides a technique decision table,
lists pitfalls, and ends with a production checklist alongside our
View Transitions API guide
and
Core Web Vitals guide.
What scroll-driven animations solve
For years, tying animation progress to scroll meant polling geometry on every frame. Libraries like GSAP ScrollTrigger abstract the math, but still run JavaScript on the main thread and must be torn down when DOM nodes unmount. Scroll-driven animations move the relationship into the compositor pipeline: the browser maps scroll offset directly to animation progress, so a progress bar, parallax layer, or reveal effect updates without a listener loop.
Typical use cases:
- Reading progress indicators that fill as the user scrolls through an article.
- Section reveal fades where opacity ramps from 0 to 1 as each block enters the viewport.
- Parallax backgrounds that translate slower than foreground content.
- Sticky header shrink tied to how far the user has scrolled past a hero.
- Scrubbed storytelling where illustration frames advance with scroll on landing pages.
Scroll-driven animations complement — not replace — Intersection Observer for one-shot lazy loading and View Transitions for route morphs. Use scroll-driven CSS when progress must be continuous and reversible as the user scrolls up; use IO when you only need a binary “entered viewport” trigger.
Core concepts: timelines and ranges
Scroll progress vs view progress
Two timeline types drive most patterns:
scroll()/scroll-timeline— progress tracks how far a scroll container has moved between its start and end. A document-level progress bar uses the root scroller.view()/view-timeline— progress tracks how an element moves through a scrollport (usually the viewport). Section reveals use view timelines on each card.
Attach a timeline to an animation with animation-timeline.
Control when along that timeline the keyframes run with
animation-range — for example
entry 0% entry 100% fades an element in as it crosses
the viewport bottom edge.
Minimal progress bar example
.progress {
position: fixed;
top: 0;
left: 0;
height: 3px;
width: 0%;
background: linear-gradient(90deg, #7c3aed, #10b981);
transform-origin: left;
animation: grow-progress auto linear;
animation-timeline: scroll();
}
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
animation-timeline: scroll() is shorthand for an anonymous
scroll timeline on the nearest scroll ancestor (the root scroller for
most pages). auto duration tells the engine to derive
timing from scroll distance instead of seconds.
Section reveal with view timeline
.guide-section {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 10% cover 40%;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(1.5rem); }
to { opacity: 1; transform: translateY(0); }
}
Each section animates independently as it enters the viewport. No per-element JavaScript, no hard-coded pixel thresholds, and scrolling backward reverses the fade naturally.
Named timelines and scroll containers
When multiple elements share one scroller — a modal with its own overflow, a horizontal carousel inside a vertical page — declare a named timeline on the scroll container:
.modal-body {
overflow-y: auto;
scroll-timeline: --modal-scroll block;
}
.modal-progress {
animation: grow-progress auto linear;
animation-timeline: --modal-scroll;
}
The block axis keyword selects vertical scroll progress;
use inline for horizontal carousels. Named view timelines
work similarly: set view-timeline: --card-inview on the
subject and reference animation-timeline: --card-inview on
a child pseudo-element for decorative overlays.
Pair with scroll-timeline-axis and
view-timeline-axis when the default block axis does not
match your layout (e.g. a sideways-scrolling gallery).
animation-range in practice
animation-range is the fine-grained control surface. Common
patterns:
entry 0% entry 100%— animate while the element's leading edge crosses the scrollport.cover 0% cover 100%— animate while the element fully spans the scrollport (good for pinned sections).exit 0% exit 100%— animate as the element leaves upward.entry-crossing 50%— shorthand for midpoint triggers.
Combine ranges with animation-range-start and
animation-range-end when you need different units —
e.g. start at entry 0% but end at cover 60%
so the fade completes before the section reaches the vertical center.
For parallax, map a small translate range to a long scroll segment:
animation-range: 0% 100% on a scroll()
timeline with keyframes from translateY(0) to
translateY(-80px). Keep parallax amplitude modest;
large shifts cause layout thrash and motion sickness.
Harbor Commerce refactor walkthrough
Harbor's mattress comparison guide had three animated surfaces:
a fixed top progress bar, staggered section fades, and a hero image
that translated slightly slower than body copy. The legacy script
attached one scroll listener on window,
queried twelve section nodes, and batch-updated styles inside rAF.
Step 1: progress bar
Delete the listener. Add a <div class="read-progress">
sibling to the header with the grow-progress keyframes and
animation-timeline: scroll(). Use transform: scaleX
instead of width so the bar animates on the compositor
without triggering layout.
Step 2: section reveals
Add a shared class to each <section> with
animation-timeline: view() and
animation-range: entry 15% cover 35%. Stagger is
unnecessary — each section's view timeline starts when
that element enters, producing organic sequential reveals
without animation-delay hacks.
Step 3: hero parallax
Wrap the hero image in a container with view-timeline: --hero
and animate the image child with a subtle translateY range.
Cap movement at 40px; larger values clipped on short viewports.
Step 4: reduced motion
@media (prefers-reduced-motion: reduce) {
.read-progress,
.guide-section,
.hero-image {
animation: none;
}
.guide-section { opacity: 1; transform: none; }
}
Result: zero scroll listeners, 18/18 Lighthouse performance unchanged, and CLS held at 0 because transforms replaced width animations.
Technique decision table
| Need | Scroll-driven CSS | Intersection Observer | JS ScrollTrigger / rAF |
|---|---|---|---|
| Continuous progress bar | Best fit | Poor (binary only) | Works but heavier |
| Reversible scrub on scroll up | Native | Awkward | Manual state |
| Lazy load images once | Overkill | Best fit | Overkill |
| Complex pin + scrub storytelling | Partial (cover range) | Insufficient | Most flexible today |
| Nested scroll container progress | Named scroll-timeline | Per-root option | Manual rect math |
| IE11 / legacy WebView | No support | Polyfill available | Universal fallback |
Performance and accessibility
Prefer transform and opacity keyframes —
they composite without layout. Avoid animating width,
height, top, or margin on
scroll-linked timelines; each frame can force reflow across long
documents.
Honor prefers-reduced-motion: reduce by disabling
scroll-driven animations entirely or replacing them with instant
state. Screen readers do not announce scroll progress; keep the
progress bar role="progressbar" with
aria-valuenow updated only if you add a tiny progressive
enhancement — pure CSS progress bars are decorative unless
paired with a live region.
Test on mobile browsers: momentum scrolling and overscroll bounce can
push view timelines past 100% briefly. Use animation-fill-mode: both
to hold end states and avoid flicker at range boundaries.
Browser support and fallbacks
Scroll-driven animations landed in Chromium 115+ and Safari 26+. Firefox support is behind a flag in some releases — verify current status before dropping JavaScript entirely. Gate with:
@supports (animation-timeline: scroll()) {
.read-progress { animation: grow-progress auto linear;
animation-timeline: scroll(); }
}
Outside @supports, show a static progress bar at 0% or
skip parallax. Do not block content behind animations; sections should
be readable with animations disabled.
Common pitfalls
- Wrong scroll ancestor —
scroll()binds to the nearest scrollable ancestor. A parent withoverflow: autosteals the timeline from the document. - Animating layout properties — width/height keyframes on scroll timelines jank on long pages. Use transforms.
- Ignoring reduced motion — parallax and fade-ins violate accessibility expectations without a media-query off switch.
- Overlapping view ranges — aggressive
coverranges on tall sections keep multiple animations active simultaneously, increasing GPU cost. - Confusing IO and view timelines — IO fires callbacks; view timelines scrub continuously. Pick one model per effect.
- Fixed positioning surprises — view timelines on
position: fixedelements behave differently; test sticky headers separately. - No fallback for Firefox — ship static layout or a minimal JS polyfill until support matches your audience.
Production checklist
- Choose
scroll()for document progress;view()for per-element reveals. - Animate only
transformandopacitywhere possible. - Set explicit
animation-rangeso fades complete before sections center. - Name timelines when multiple scrollports exist on one page.
- Wrap in
@supports (animation-timeline: scroll())with static fallback. - Disable all scroll-driven motion under
prefers-reduced-motion: reduce. - Verify nested
overflowcontainers do not hijack the root timeline. - Test reverse scroll: animations must scrub backward smoothly.
- Measure CLS and INP before/after removing scroll listeners.
- Document which effects are decorative vs essential for accessibility review.
Key takeaways
- Scroll-driven animations map keyframe progress to scroll offset without JavaScript listeners.
scroll()timelines suit progress bars;view()timelines suit section reveals.animation-rangecontrols exactly when along entry, cover, or exit phases keyframes run.- Pair with Intersection Observer for lazy loading and View Transitions for route morphs — different jobs.
- Ship @supports fallbacks and honor reduced-motion preferences on every scroll-linked effect.
Related reading
- Intersection Observer API explained — one-shot viewport triggers and lazy loading
- CSS content-visibility explained — skip rendering off-screen sections
- View Transitions API explained — morph between pages and DOM states
- Core Web Vitals explained — LCP, INP, and CLS budgets for animated pages