For years, “animate on scroll” meant a scroll listener, requestAnimationFrame, and careful math. Modern CSS can do most of it natively with animation-timeline, and it runs off the main thread.
Reading progress bar
A bar at the top of the page that fills as you read. This is the whole implementation:
.progress {
position: fixed; inset: 0 0 auto; height: 3px;
background: #136df5;
transform-origin: left;
animation: grow linear both;
animation-timeline: scroll(root);
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }scroll(root) maps the animation’s 0–100% to the page’s scroll position. No listener, no state.
Reveal elements as they enter
view() tracks an element’s position in the viewport instead of the page’s:
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 10% cover 30%;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(24px); }
}animation-range controls when it plays. Here it starts as the element enters and finishes when it’s about a third of the way up the screen.
Always ship a fallback
Browser support is good but not universal. Wrap scroll-driven rules in a feature query so other browsers get static, fully visible content:
@supports (animation-timeline: view()) {
.reveal { /* scroll-driven rules here */ }
}And respect motion preferences:
@media (prefers-reduced-motion: reduce) {
.reveal, .progress { animation: none; }
} When to still use JavaScript
Use JS when the animation needs to trigger once and stay, like a counter that ticks up, or when it depends on something besides scroll position. For anything that should scrub smoothly with the scrollbar, CSS is now the better tool.
There’s a copy-ready scroll progress bar in the library.