DEV Community

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

Posted on Edited on Originally published at bestpractic.org

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.

🧠 Test yourself

Think it clicked? Take the 6-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

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)