Scroll-Driven Animations in CSS

WebCSS
Share on LinkedIn

Scroll-jacking with JavaScript scroll listeners caused jank for a decade—main thread blocked, passive listener debates, libraries fighting each other. Scroll-driven animations move scroll coupling into the compositor: declare animation-timeline: view() and keyframes run from scroll geometry, not setTimeout. Parallax headers and reading progress bars become CSS problems.

View timeline: reveal on scroll

.reveal {
  animation: fade-slide linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

@keyframes fade-slide {
  from {
    opacity: 0;
    transform: translateY(2rem);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

animation-range: entry 0% cover 40% runs animation from element entering viewport until 40% covered.

Named scroll timeline

.scroller {
  overflow-y: auto;
  height: 100vh;
  scroll-timeline: --section-scroll block;
}

.progress-bar {
  animation: grow-width linear;
  animation-timeline: --section-scroll;
}

@keyframes grow-width {
  from { width: 0%; }
  to { width: 100%; }
}

Progress bar tracks .scroller vertical scroll.

scroll() shorthand

.header {
  animation: shrink-header linear both;
  animation-timeline: scroll(root block);
}

Animates based on document root scroll block axis.

Parallax-like effect

.hero-image {
  animation: parallax linear;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

@keyframes parallax {
  from { transform: translateY(-10%); }
  to { transform: translateY(10%); }
}

Subtle Y shift as image crosses viewport—lighter than multi-layer JS parallax.

Sticky header shrink

.site-header {
  position: sticky;
  top: 0;
  animation: compact linear both;
  animation-timeline: scroll(root block);
  animation-range: 0 200px;
}

@keyframes compact {
  from { padding-block: 1.5rem; font-size: 1.5rem; }
  to { padding-block: 0.5rem; font-size: 1rem; }
}

Header compacts over first 200px of document scroll.

Reduced motion

@media (prefers-reduced-motion: reduce) {
  .reveal {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Respect user preference—scroll animations can disorient vestibular-sensitive users.

Fallback strategy

if (!CSS.supports('animation-timeline: view()')) {
  // IntersectionObserver adds .visible class
}
.reveal { opacity: 0; }
.reveal.visible { opacity: 1; transition: opacity 0.5s; }

Performance notes

Compositor-driven when properties are transform/opacity only—avoid animating width/height on scroll. Test on low-end mobile; many simultaneous view timelines add cost.

How scroll timelines work under the hood

Understanding the mechanism helps debug "why isn't my animation running?" Scroll-driven animations tie animation-progress to a timeline progress value between 0 and 1. For view(), progress tracks how much of the subject element has crossed the scrollport according to animation-range. For scroll(), progress tracks scroll offset within the scroll container. The animation runs on the compositor thread when you stick to transform and opacity — the main thread never sees per-frame scroll events.

This is fundamentally different from scroll event listeners that fire dozens of times per second on the main thread. Scroll-driven animations sample scroll position at composite time, similar to how position: sticky works — declarative coupling to scroll geometry rather than imperative callbacks.

Choosing between view() and scroll()

Use case Timeline Why
Element fade-in on enter view() Progress tied to element visibility
Reading progress bar scroll() Progress tied to container scroll position
Sticky header shrink scroll(root block) Document scroll, not element visibility
Horizontal carousel item highlight view(inline) Inline axis for horizontal scrollport
Parallax on hero image view() with range Subtle shift as element traverses viewport

Named timelines (scroll-timeline: --foo) decouple the scrolling element from the animated element — useful when the progress bar lives outside the scroll container in the DOM tree but should track its scroll.

animation-range tuning

The range syntax is the steepest learning curve. Read entry 0% cover 40% as: "start when the element begins entering, finish when 40% of the element is covered by the scrollport."

/* Slow reveal — animation spans most of viewport traversal */
.wide-reveal {
  animation-range: entry 0% cover 80%;
}

/* Snappy pop-in — short range near entry edge */
.snappy-reveal {
  animation-range: entry 0% entry 100%;
}

/* Exit animation — runs as element leaves */
.fade-out {
  animation-range: exit 0% exit 100%;
  animation-direction: reverse;
}

Test ranges in Chrome DevTools Animation panel — scrub scroll and watch progress. Wrong ranges produce "nothing happens" or "animation already finished on load."

Stacking multiple scroll animations

Product pages often combine a document-level progress bar, per-section reveals, and a sticky header shrink. Each creates a scroll timeline. On low-end devices, ten simultaneous view() animations on a long page can stress the compositor:

@keyframes set-progress {
  to { --scroll-progress: 1; }
}

.scroller {
  animation: set-progress linear;
  animation-timeline: scroll(self);
  animation-fill-mode: both;
}

.child {
  opacity: calc(var(--scroll-progress, 0));
}

Debugging checklist

Accessibility and UX

Scroll-driven animations can trigger vestibular symptoms when large elements move horizontally or zoom during scroll. Always wrap in prefers-reduced-motion: reduce and provide static fallbacks. Screen readers ignore scroll animations entirely — don't hide content behind animation completion; elements should be readable at full opacity regardless.

For infinite scroll feeds, view() re-triggers when elements re-enter — may cause flicker. Use animation-fill-mode: both and consider one-shot IntersectionObserver fallback for feeds where re-entry animation is undesirable.

Production checklist

Resources

Frequently asked questions

What are CSS scroll-driven animations?

Scroll-driven animations tie animation progress to scroll position instead of time. animation-timeline: scroll() tracks scroll container progress; animation-timeline: view() tracks element visibility in scrollport. Keyframes advance as user scrolls—progress bars, fade-ins, parallax without scroll event listeners.

Do scroll-driven animations work without JavaScript?

Yes in supporting browsers—Chrome 115+, Edge, Safari 26+. No requestAnimationFrame, no IntersectionObserver for basic reveal effects. JS still helps for complex orchestration and fallbacks on unsupported browsers.

What is the difference between scroll() and view() timelines?

scroll() binds to scroll container progress—0% at top, 100% at bottom of scrollable area. view() binds to element intersection with scrollport—animation runs as element enters, crosses, or exits view. Use view() for reveal-on-scroll; scroll() for page-wide progress indicators.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →