DEV Community

Cover image for Your scroll listener is doing CSS's job
Parsa Jiravand
Parsa Jiravand

Posted on • Edited on

Your scroll listener is doing CSS's job

You've written this, or something very close to it:

window.addEventListener('scroll', () => {
  const scrolled   = window.scrollY;
  const total      = document.documentElement.scrollHeight - window.innerHeight;
  const progress   = (scrolled / total) * 100;
  progressBar.style.width = `${progress}%`;
});
Enter fullscreen mode Exit fullscreen mode

Three lines of arithmetic, firing on every scroll event. On a 120 Hz display, that callback runs 120 times a second. If anything else is competing for the main thread at that moment — a re-render, a layout calculation, a lazy-loaded image — the bar stutters.

All that effort, and the whole thing is animating a div with a background-color.

Here's the same effect in CSS:

@keyframes grow-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

#progress-bar {
  transform-origin: left;
  animation: grow-progress linear;
  animation-timeline: scroll();
}
Enter fullscreen mode Exit fullscreen mode

No event listener. No math. No requestAnimationFrame. The browser wires the animation to the scroll position directly — and because it runs on the compositor thread, it doesn't compete with JavaScript at all.

What scroll() actually is

animation-timeline: scroll() creates a timeline whose progress maps to the scroll position of the nearest scrollable ancestor — by default, the root <html> element. When you're at the top, the timeline is at 0%. At the bottom, it's at 100%. In between, it's linear. The @keyframes animation plays against that timeline exactly as if you'd written the numbers yourself.

You can control the target scrollable and the axis:

/* default — root, vertical (block) axis */
animation-timeline: scroll();

/* the element's own scroll container */
animation-timeline: scroll(self);

/* horizontal scroll */
animation-timeline: scroll(inline);
Enter fullscreen mode Exit fullscreen mode

The reading-progress bar is the obvious case. Parallax effects are the other one: a background image that moves at a different speed than the page. What used to require scroll listeners and translate math is now two CSS properties pointing at the same timeline.

view() — the IntersectionObserver you never wanted to write

scroll() is driven by the document-level scroll position. view() is driven by where a specific element sits relative to the viewport — it plays as the element enters and exits the scrollport.

This is the pattern you've reached for IntersectionObserver to fake:

// The old way
const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
    }
  });
}, { threshold: 0.1 });

document.querySelectorAll('.card').forEach(card => observer.observe(card));
Enter fullscreen mode Exit fullscreen mode

Plus the companion CSS that lives in .card.visible. Here's the same effect without any JavaScript:

@keyframes reveal {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: translateY(0); }
}

.card {
  animation: reveal linear forwards;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
Enter fullscreen mode Exit fullscreen mode

Every .card fades and lifts in as it enters the viewport. One rule, zero observers, zero classList mutations.

Controlling the timing with animation-range

Without animation-range, view() plays across the entire time an element is anywhere in the viewport — entry, full screen, exit — which usually isn't what you want. animation-range pins the animation to a specific phase.

The phases map to where the element is relative to the scrollport boundary:

/* Animate only while the element is entering */
animation-range: entry 0% entry 100%;

/* Animate while the element crosses the center of the viewport */
animation-range: contain 0% contain 100%;

/* Animate while the element is leaving */
animation-range: exit 0% exit 100%;
Enter fullscreen mode Exit fullscreen mode

entry 0% is the moment the element's leading edge first touches the viewport. entry 100% is when it's fully inside. Use forwards fill mode to hold the final state — otherwise the element snaps back when the range ends.

Quick prediction: if you set animation-range: entry 0% exit 100%, what does the animation do as the user scrolls the element back off the top of the screen?

It reverses — the element fades back out as it exits. Scroll-driven animations play in both directions by default, tracking position, not time. Add animation-fill-mode: forwards if you want it to hold.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

Where JavaScript still belongs

Scroll-driven animations aren't a full IntersectionObserver replacement. The distinction is about what kind of response you need.

Reach for CSS when you need continuous, position-linked motion: progress bars, parallax, reveal effects, sticky header opacity fading in as you scroll past a threshold. The animation tracks the scroll position in real time.

Keep JavaScript when you need one-shot logic on threshold: lazy loading an image when it enters view, firing an analytics event, adding a class that won't reverse. IntersectionObserver is a better model for "something happened once at this scroll depth."

The smell that tells you you're in the wrong tool: you're writing a scroll event listener that does math, then sets a style property. If the output is always a CSS value derived from a scroll position, CSS can own it.

Browser support — the honest picture

Chrome 115 (mid-2023) shipped scroll-driven animations. Firefox followed in Firefox 128 (mid-2024). Safari support arrived in Safari 18 (late 2024). As of mid-2025, you're looking at good baseline coverage, but still worth a @supports guard on anything production-critical:

@supports (animation-timeline: scroll()) {
  #progress-bar {
    animation: grow-progress linear;
    animation-timeline: scroll();
  }
}
Enter fullscreen mode Exit fullscreen mode

Browsers without support silently skip the block. The content is still there; the enhancement just doesn't show up. That's the exact progressive-enhancement story view transitions and container queries tell — pick it up where supported, ignore it where not.

Stop writing scroll listeners for CSS problems

That event listener has been load-bearing for so long it's easy to forget it was always a workaround. The scroll position was always data the browser had. The motion was always a style property. The glue between them — the event, the math, the style.width = — was the gap the platform hadn't closed yet.

animation-timeline closes the gap. The scroll listener you're writing today for a progress bar or a reveal effect is doing CSS's job, on your thread, with your budget.

The rule for next time: reach for a scroll event listener when you need to react to scroll with logic. Reach for animation-timeline when you need to react with motion. Most reveal effects and progress indicators are motion, and they always were.

What scroll effect are you currently holding together with a 60-fps event listener — and how small does the CSS version turn out to be?


Thanks for reading! Let's stay connected:

Top comments (0)