An ongoing series on techniques that ship with the browser already - no library, no build step required.
A progress bar that fills as you scroll is a small piece of UI with a surprising amount of JavaScript behind it: a scroll listener, usually throttled or wrapped in requestAnimationFrame so it doesn’t run too often, computing scrollTop over scrollHeight on every tick and writing the result to a width or a transform. It works, but it’s a chunk of code running on every single scroll frame for something that’s really just a number between 0 and 1.
Scroll this box, not the page, and watch the thin bar pinned to its top:
Everything driving that bar is CSS - no scroll listener, no per-frame style write, nothing running on the main thread while you scroll.
Keep going.
The bar's own animation is tied to this box's scroll position instead of the clock, so "how far through the animation" and "how far through the scroll" are the same number, always.
Almost there.
That's the bottom.
HTML
<div class="sdp-demo">
<div class="sdp-scroll">
<div class="sdp-bar-track">
<div class="sdp-bar"></div>
</div>
<div class="sdp-content">
<p>
Everything driving that bar is CSS - no scroll listener, no per-frame
style write, nothing running on the main thread while you scroll.
</p>
<p>Keep going.</p>
<p>
The bar's own animation is tied to this box's scroll position instead
of the clock, so "how far through the animation" and "how far through
the scroll" are the same number, always.
</p>
<p>Almost there.</p>
<p>That's the bottom.</p>
</div>
</div>
</div>CSS
.sdp-demo {
border: 1px solid var(--color-gray-200);
}
.sdp-scroll {
height: 180px;
overflow-y: auto;
position: relative;
}
.sdp-bar-track {
position: sticky;
top: 0;
height: 4px;
background: var(--color-gray-100);
}
.sdp-bar {
height: 100%;
background: var(--accent);
}
@supports (animation-timeline: scroll()) {
.sdp-bar {
transform: scaleX(0);
transform-origin: left;
animation-name: sdp-fill;
animation-timeline: scroll(nearest block);
animation-fill-mode: both;
}
}
@keyframes sdp-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.sdp-content {
padding: 1rem 1.25rem;
font-size: 0.88rem;
line-height: 1.6;
color: var(--color-gray-600);
}
.sdp-content p {
margin: 0 0 1rem;
}
.sdp-content p:last-child {
margin-bottom: 0;
}No event fired while that happened. The bar’s fill is an animation, and its timeline just happens to be “how far this box is scrolled” instead of a duration in seconds.
The whole idea
.bar {
animation-name: fill;
animation-timeline: scroll(nearest block);
animation-fill-mode: both;
}
@keyframes fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
An animation’s timeline is normally the clock - animation-duration: 2s means “play this over two seconds, starting now.” animation-timeline: scroll(nearest block) swaps that clock out for a scroll position instead: it looks for the nearest scrollable ancestor, and along its block (vertical) axis, 0% scrolled maps to the start of the keyframes and 100% scrolled maps to the end, tracking everything between exactly, whether it happens over one fast flick or a slow drag.
The one structural rule that comes with it: the animated element has to actually live inside the scroller, since scroll() finds its timeline by looking at its own ancestors. That’s why the bar in the demo sits inside the scrolling box rather than above it, pinned to the top with position: sticky so it stays put while the paragraphs scroll past underneath it.
Treat it as a bonus, not a foundation
This is newer than everything else in this collection, and support isn’t as uniformly settled as :has() or subgrid at this point. The fix is the same one the accordion post used for animating <details>: wrap the enhancement in @supports and give the element a sane default outside it. Here, that default is a plain, fully-visible bar - a reasonable static state on its own - with the scroll-linked collapse-and-reveal layered on only where @supports (animation-timeline: scroll()) passes. Nobody sees anything broken either way; some visitors just get the animated version.
Worth being specific about “not as uniformly settled,” rather than just asserting it:
Browser support · animation-timeline: scroll()
Support data from MDN’s browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.
Firefox’s entry is the one to note: it’s implemented, but sitting behind the layout.css.scroll-driven-animations.enabled flag rather than switched on for everyone, which is exactly the kind of gap @supports and a sane default are for.
Where the win actually is
The version most sites ship is doing real work on every scroll frame - a listener firing, a percentage recalculated, a style written - competing for the main thread with everything else happening on the page at that moment. A scroll-driven animation hands that job to the compositor, the same part of the browser already handling the scroll itself, so there’s nothing left competing for the main thread’s attention. It’s a small-sounding change with the same shape as most of what actually makes a page feel fast: rarely one big fix, usually one less thing running where it didn’t need to.
It’s also the second time in this collection that leaning on native scrolling has replaced a chunk of JavaScript outright. The pure-CSS carousel did it for swiping between slides. This is the same instinct applied to something that reads the scroll instead of just responding to it.



