Skip to main content

Refactoring Parallax Effects: Replacing Scroll Event Listeners with CSS Scroll-Timeline

 For years, the standard implementation for scroll-linked animations—parallax hero images, reading progress bars, and scroll-triggered fade-ins—has been imperative JavaScript. We attach listeners to the window, calculate scrollTop, normalize the value, and imperatively update DOM styles.

Even with aggressive throttling, debouncing, or wrapping updates in requestAnimationFrame, this approach suffers from a fundamental architectural flaw: it binds visual updates to the Main Thread.

On mobile devices with high refresh rate displays (90Hz+), the main thread is often busy parsing HTML, hydrating frameworks like React, or executing third-party scripts. When the main thread hangs, your scroll listener hangs. The result is "jank"—a visual stutter where the scroll momentum (handled by the browser's compositor) desynchronizes from the JavaScript-driven styles.

The solution is to decouple scroll animations from JavaScript execution entirely using the CSS Scroll-driven Animations Specification (animation-timelinescroll(), and view()).

The Root Cause: The Main Thread Bottleneck

To understand why addEventListener('scroll', ...) is expensive, we must look at the browser rendering pipeline.

  1. Input: User swipes.
  2. JavaScript: The scroll event fires. Your code runs.
  3. Style/Layout: If you modify geometry (height, top, margins) or read layout properties (offsetTop), you trigger a Reflow.
  4. Paint: The browser repaints pixels.
  5. Composite: The GPU composites layers.

Modern browsers handle scrolling on a separate Compositor Thread. This allows the page to scroll smoothly even if the Main Thread is locked up.

However, when you use a JS scroll listener, the browser must sync the Compositor Thread with the Main Thread to execute your logic. If you are updating transform or opacity via JS, you are effectively forcing the scrolling logic to wait for the JavaScript engine. If the JS execution exceeds the frame budget (8ms on a 120Hz screen), the animation drops frames.

We solve this by moving the animation logic strictly to the CSS and the Compositor.

The Fix: CSS Scroll-driven Animations

The Scroll-driven Animations specification (Level 1) allows us to bind the playback progress of an existing CSS animation to the scroll offset of a scroll container.

We will refactor two common patterns:

  1. A Reading Progress Bar (linked to the scroll position of the viewport).
  2. A Parallax Image (linked to the element's visibility within the viewport).

1. Refactoring the Reading Progress Bar

The Legacy JS Approach:

// The bottleneck
window.addEventListener('scroll', () => {
  const scrolled = document.documentElement.scrollTop;
  const max = document.documentElement.scrollHeight - document.documentElement.clientHeight;
  const scrollPercent = (scrolled / max) * 100;
  document.getElementById('progress-bar').style.width = `${scrollPercent}%`;
});

The Modern CSS Approach:

We use an anonymous scroll progress timeline using the scroll() functional notation.

/* keyframes define the start and end state */
@keyframes grow-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 8px;
  background: #646cff;
  transform-origin: 0 50%;
  z-index: 1000;
  
  /* Bind animation to the scroll position of the nearest scroller (root) */
  animation-name: grow-progress;
  animation-timing-function: linear;
  animation-timeline: scroll(root block);
}

How it works:

  • scroll(root block): Defines the timeline. It tracks the root (viewport) scrolling on the block (vertical) axis.
  • The animation progress (0% to 100%) maps strictly to the scroll distance (0% to 100%).
  • No JavaScript runs. The browser composes this animation on the GPU.

2. Refactoring Parallax Images

Parallax is more complex because it is usually relative to a specific element entering and leaving the viewport, not the entire page scroll. For this, we use the view() function.

The Implementation:

We want a hero image that translates along the Y-axis slower than the user scrolls, creating depth.

<section class="hero-container">
  <div class="parallax-image"></div>
  <div class="hero-content">
    <h1>High Performance Parallax</h1>
  </div>
</section>
@keyframes parallax-shift {
  from {
    /* Image starts slightly shifted up */
    transform: translateY(-10%);
  }
  to {
    /* Image ends slightly shifted down */
    transform: translateY(10%);
  }
}

.hero-container {
  height: 80vh;
  overflow: hidden;
  position: relative;
  display: grid;
  place-items: center;
}

.parallax-image {
  position: absolute;
  inset: 0;
  background-image: url('/heavy-asset.jpg');
  background-size: cover;
  background-position: center;
  z-index: -1;
  /* Ensure the element is taller than container to allow movement without gaps */
  height: 120%; 
  top: -10%; 

  /* The Magic */
  animation-name: parallax-shift;
  animation-timing-function: linear;
  
  /* Track when THIS element intersects the viewport */
  animation-timeline: view();
  
  /* Define the range: 
     Start animation when top of element enters viewport.
     End animation when bottom of element leaves viewport. */
  animation-range: entry-crossing 0% exit-crossing 100%;
}

Technical Breakdown

Anonymous Timelines (scroll vs view)

  1. animation-timeline: scroll(): This is for animations linked to the scroll position of a container (usually the viewport). The animation duration matches the entire scrollable distance. This replaces generic "scroll progress" listeners.

  2. animation-timeline: view(): This is for animations linked to an element's visibility within the scrolling port. It effectively replaces IntersectionObserver logic combined with scroll calculations.

Animation Ranges

The animation-range property is critical for fine-tuning view() timelines. Without it, the animation tracks the element from the moment it is infinitesimally visible until it is completely gone.

Common values include:

  • cover: The full range from first pixel entry to last pixel exit.
  • contain: The range where the element is fully fully visible inside the port.
  • entry: From 0% visible (start) to 100% visible (start).

In the parallax example above, entry-crossing 0% exit-crossing 100% ensures the transformation is perfectly distributed across the time the element is physically on the screen.

Handling Browser Support

As of 2024, support is strong in Chromium (Chrome, Edge) and Firefox. Safari support is pending (often available behind feature flags).

To ensure robustness, use a feature query. This creates a "Progressive Enhancement" approach where the animation simply doesn't play on unsupported browsers, rather than breaking the UI, or you can fall back to a JavaScript polyfill only when necessary.

@supports (animation-timeline: view()) {
  .parallax-image {
    animation-timeline: view();
  }
}

For a seamless fallback, Google Chrome Labs maintains a zero-dependency polyfill that maps these CSS rules to Web Animations API calls automatically:

npm install scroll-timeline-polyfill

Import it once in your entry file:

import 'scroll-timeline-polyfill';

Conclusion

By moving scroll-linked effects from the Main Thread (JavaScript) to the Compositor Thread (CSS), we eliminate the primary source of scroll jank. This is not just a syntax change; it is a shift from imperative programming ("do this on every pixel scrolled") to declarative programming ("map this animation state to this scroll position").

The result is 120fps animations on mobile devices, zero impact on your TBT (Total Blocking Time), and a significantly cleaner codebase.