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 ancestorscroll() binds to the nearest scrollable ancestor. A parent with overflow: auto steals 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 cover ranges 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: fixed elements 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 transform and opacity where possible.
  • Set explicit animation-range so 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 overflow containers 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-range controls 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