← blog
CSSJuly 6, 2026 · 5 min read

Your scroll listener is doing CSS's job

Reading progress bars, reveal-on-scroll effects, parallax — you've been writing these in JavaScript. CSS scroll-driven animations bind them directly to scroll position, off the main thread, in a handful of lines.

Parsa Jiravand · Frontend engineer · building bestpractic
Your scroll listener is doing CSS's job

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

JavaScript
1
2
3
4
5
6
window.addEventListener('scroll', () => { const scrolled = window.scrollY; const total = document.documentElement.scrollHeight - window.innerHeight; const progress = (scrolled / total) * 100; progressBar.style.width = `${progress}%`; });

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:

CSS
1
2
3
4
5
6
7
8
9
10
@keyframes grow-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } } #progress-bar { transform-origin: left; animation: grow-progress linear; animation-timeline: scroll(); }

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.

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:

CSS
1
2
3
4
5
6
7
8
/* default — root, vertical (block) axis */ animation-timeline: scroll(); /* the element's own scroll container */ animation-timeline: scroll(self); /* horizontal scroll */ animation-timeline: scroll(inline);

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.

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:

JavaScript
1
2
3
4
5
6
7
8
9
10
// 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));

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

CSS
1
2
3
4
5
6
7
8
9
10
@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%; }

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

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:

CSS
1
2
3
4
5
6
7
8
/* 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%;

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.

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

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.

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:

CSS
1
2
3
4
5
6
@supports (animation-timeline: scroll()) { #progress-bar { animation: grow-progress linear; animation-timeline: scroll(); } }

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.

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

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:

Originally published on dev.to