Scroll-Driven Progress Bar
Six lines of CSS. No scroll listener, no layout thrash. The bar at the top of this page is the snippet running — scroll and watch it.
The snippet
grow { { transform: scaleX(0); } } .progress { position: fixed; inset: 0 0 auto; height: 3px; background: ; transform-origin: 0 50%; animation: grow linear both; : scroll(root block); }
One <div class="progress"> anywhere in the document. It does not matter where — position: fixed takes it out of flow and the timeline is the document’s, not the element’s.
Line by line
to state is whatever the element already is, so it can be left out. Writing to { transform: scaleX(1) } is the same thing spelled longer.transform-origin: 0 50% is what makes it grow from the left instead of the centre.from keyframe, 100% is the to. linear keeps the bar honest — an eased curve would report the wrong position.root is the document; block is the vertical axis in a horizontal writing mode. The default is scroll(nearest block), which is a different bar the moment your page sits inside a scroll container.Scoped to a container
The bar does not have to track the page. Name a timeline on any scroll container and any element can consume it, in or out of that container. Scroll the panel below.
.panel {
: --panel;
}
.panel__scroller {
overflow-y: auto;
: --panel block;
}
.panel__bar {
transform-origin: 0 50%;
animation: grow linear both;
: --panel;
}What it replaces
The JavaScript equivalent is not long either, and that is the point — it is short and still wrong in three ways.
addEventListener(, () => { max = document.body.scrollHeight - innerHeight; bar.style.transform = ${scrollY / max}; });
scrollHeight forces a reflow per eventBefore you ship it
animation-duration. The value is ignored on a scroll timeline, so animation-duration: 1ms is a harmless line to carry.prefers-reduced-motion. It reports where you are and follows your finger exactly; there is no unrequested movement to suppress. Parallax built the same way is a different question.role="progressbar", no live region, no aria-valuenow you would then have to keep updated in JavaScript — which would put back the listener this snippet exists to remove.The interesting part is not the bar. It is that scroll position is now a value CSS can read, so a whole category of effect that used to require a listener, a cached measurement, and a teardown path is a property you set on the element that moves.