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}%`;
});
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();
}
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);
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));
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%;
}
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%;
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();
}
}
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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (0)