Scroll-Driven Animations Without JavaScript

This guide is part of Animation & Compositing Performance, within Rendering & CSS Performance. Reading progress bars, reveal-on-scroll effects, parallax backgrounds, shrinking headers and sticky-section transitions have traditionally been built with scroll event listeners: read the scroll position, compute a progress value, write a style. Even when carefully throttled, these handlers run on the main thread, often read layout, and lag behind the actual scroll because scrolling itself happens on the compositor. When the main thread is busy, the effect stutters or freezes while the page keeps scrolling.

CSS scroll-driven animations link an ordinary CSS animation to scroll progress instead of time. With animation-timeline: scroll() an animation tracks the scroll position of a scroller; with animation-timeline: view() it tracks an element's progress through the viewport. For transform and opacity animations, browsers can run these on the compositor, perfectly synchronised with scrolling and unaffected by main-thread work.

Scroll listener vs scroll-driven animation Comparison of a JavaScript scroll listener and a CSS scroll-driven animation for scroll-linked effects. Scroll listener vs scroll-driven animation Scroll listener • Runs on the main thread per event • Often reads layout (forced reflow risk) • Lags or freezes when the thread is busy • Needs throttling and cleanup Scroll-driven animation • Declarative CSS, no script • Compositor-driven for transform/opacity • Stays in sync with scrolling • Progressive enhancement where unsupported

Rapid Diagnosis

  • Search for scroll listeners that set styles based on scrollY or element positions.
  • Record a trace while scrolling: handler tasks every frame indicate main-thread scroll effects.
  • Check visual lag: effects that trail behind fast scrolls or jump when the page is busy.
  • Check browser support for your audience; unsupported browsers need a fallback (often no animation).

Root Cause Analysis

1. Scroll is compositor-driven; listeners are not. The page scrolls smoothly while the effect waits for the main thread.

2. Layout reads in handlers. Computing element progress via getBoundingClientRect() risks forced reflow.

3. High event frequency. Handlers run on every scroll frame.

4. Main-thread contention. Hydration, data loading and other work block the handlers.

Step-by-Step Resolution

1. Reading progress bar with scroll()

css
.progress {
  position: fixed; inset: 0 0 auto 0; height: 4px; background: #0466c8;
  transform-origin: left; transform: scaleX(0);
  animation: grow linear both;
  animation-timeline: scroll(root block);
}
@keyframes grow { to { transform: scaleX(1); } }
/* trade-off: browsers without support show an empty bar; wrap in @supports to
   hide the element or keep a JS fallback only for those browsers. */

2. Reveal-on-scroll with view()

css
@supports (animation-timeline: view()) {
  .reveal {
    animation: fade-up linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 30%;
  }
  @keyframes fade-up { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: none; } }
}
/* trade-off: never apply reveal animations to above-the-fold content or the LCP
   element; starting at opacity 0 delays when it counts as painted. */

3. Parallax with transform only

css
@supports (animation-timeline: scroll()) {
  .hero-bg { animation: parallax linear both; animation-timeline: scroll(); animation-range: 0 100vh; }
  @keyframes parallax { to { transform: translateY(30%); } }
}

4. Remove the old listeners

Delete the scroll handlers (and their throttle utilities) once the CSS versions are in place, keeping a JavaScript fallback only if the effect is essential in unsupported browsers.

Scroll effect under main-thread load Timeline showing a scroll listener effect freezing during a long task while a scroll-driven animation keeps updating. Scroll effect under main-thread load Main thread long task (hydration) Listener-driven bar updates frozen catches up Scroll-driven bar updates every frame on compositor 0ms 100ms 200ms 300ms 400ms 500ms 600ms 700ms 800ms 900ms 1000ms

Verification

Record a trace while scrolling: no scroll handler tasks should appear, and the effect should remain in sync during a simulated long task (run a busy loop in the console while scrolling). Check that above-the-fold content is visible immediately on load. Test in a browser without support to confirm the fallback is acceptable.

Worked Example: A Long-Read Feature

A publisher's long-read template had a reading progress bar, section reveal animations and a parallax hero, all driven by one scroll handler that read the positions of 30 elements on each frame. On mid-range phones, scrolling showed 20–30ms handler tasks and visible stutter; INP suffered when taps landed during scroll handling. Rewriting all three effects as scroll-driven animations (with @supports guards and no fallback for reveals, since content was simply visible without them) removed the handler. Scroll frame drops disappeared in supporting browsers, and INP p75 on the template improved by 60ms.

Scroll effects and their CSS replacements Common scroll-linked effects and the scroll-driven animation features that replace JavaScript implementations. Scroll effects and their CSS replacements Effect CSS feature Properties Reading progress bar scroll(root) transform scaleX Reveal on scroll view() + animation-range opacity, translate Parallax background scroll() + range translateY Shrinking sticky header scroll() + range scale; avoid height Gallery position indicator named scroll-timeline transform

Named Timelines and Sticky Sections

For effects that depend on a specific scroller or element other than the animated one, named timelines connect them. scroll-timeline-name on a scroller or view-timeline-name on a tracked element creates a timeline that other elements can reference with animation-timeline: --name. Use timeline-scope on a common ancestor to make a named timeline visible to elements outside the subtree. This enables effects like a sticky header that changes as a particular section passes, or a horizontal gallery whose indicator tracks its own scroll, all without scripting.

css
.gallery { overflow-x: auto; scroll-timeline: --gallery inline; }
.gallery-indicator { animation: fill linear both; animation-timeline: --gallery; }
@keyframes fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }

Rolling Out as a Progressive Enhancement

Scroll-driven animations are ideal progressive enhancements: when unsupported, the content should simply appear in its final state. Write the base styles for the final state, then add the animation inside @supports (animation-timeline: scroll()). Never set an element's initial state to invisible outside the @supports block, or unsupported browsers will hide it forever. For essential effects, load a JavaScript fallback only when CSS.supports('animation-timeline: scroll()') is false, so supporting browsers never download or run the old handler.

Common Mistakes

  • Animating layout properties on a scroll timeline. They still run on the main thread; use transform and opacity.
  • Reveal animations on above-the-fold content. Delays LCP and makes the first view look empty.
  • No @supports guard. Unsupported browsers may leave elements in their initial keyframe state.
  • Keeping old listeners running. Double work and conflicting styles.

Edge Cases

Reduced motion. Respect prefers-reduced-motion by disabling scroll-driven animations or replacing movement with simple fades.

Nested scrollers. scroll() defaults to the nearest scroll ancestor; specify root or use named timelines for the intended scroller.

Animation fill. Use both fill mode so elements hold their start state before the range and their end state after it.

Accessibility of hidden content. Content that starts at opacity 0 is still in the accessibility tree; ensure it becomes visible when focused.

FAQ

Which browsers support scroll-driven animations?

Chromium-based browsers support them, and support has been arriving in other engines. Treat them as a progressive enhancement and check current support for your audience.

Do scroll-driven animations run on the compositor?

For compositor-friendly properties like transform and opacity, browsers can run them off the main thread, keeping them in sync with scrolling.

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

scroll() tracks the scroll position of a scroll container from start to end. view() tracks an element's visibility as it passes through a scroller's viewport.

Can I use scroll-driven animations with JavaScript?

Yes. The Web Animations API accepts ScrollTimeline and ViewTimeline objects, so you can create them in script while keeping the compositor benefits.

Do they affect CLS?

Transform and opacity animations do not cause layout shifts. Animating layout properties on a scroll timeline can.

What fallback should I use?

For decorative effects, no animation. For essential indicators like reading progress, a lightweight JavaScript fallback limited to unsupported browsers.

Can scroll-driven animations replace IntersectionObserver?

For visual effects, often. For logic such as loading content or analytics when elements become visible, IntersectionObserver is still the right tool.

Do scroll-driven animations work with smooth scrolling?

Yes. They follow the actual scroll position, whether the user scrolls with a wheel, touch, keyboard or scroll-behavior: smooth, so they stay synchronised in every case.

Are scroll-driven animations accessible?

They can be, if they respect reduced motion and never hide content that users need. Content should be readable even when the animation is disabled or unsupported.