Scroll-linked interface effects often start as JavaScript listeners that update styles on every scroll event. CSS scroll-driven animations move that timing into the browser animation system, so a keyframe can follow the root page scroll or an element's position inside a scrollport.
The animation-timeline property changes an animation from a time-based sequence into a scroll or view progress sequence. A page progress indicator is a narrow use case because the animated element only needs to scale from empty to full as the root scroll range advances.
Browser support is still limited in some engines, so keep the effect inside a @supports branch and make the page complete without the animation. Reduced-motion users should receive a non-moving state instead of a scroll-linked moving indicator.
Related: How to add a CSS animation
Related: How to respect reduced motion with CSS
<div class="scroll-progress" aria-hidden="true"></div> <main class="article"> <h1>Release notes</h1> <p>Content continues far enough for the page to scroll.</p> </main>
Keep the indicator decorative unless it carries information that is not available elsewhere. A scroll progress bar usually should not be announced by screen readers.
.scroll-progress { display: none; position: fixed; inset: 0 0 auto; height: 0.35rem; transform: scaleX(0); transform-origin: left center; background: linear-gradient(90deg, #0f766e, #2563eb); z-index: 1000; }
Unsupported browsers keep the page content and skip the progress bar because display: block is added only in the support query.
@keyframes page-scroll-progress { to { transform: scaleX(1); } }
The base rule starts at scaleX(0), so the keyframes only need the final scaleX(1) state.
@supports (animation-timeline: scroll(root block)) { .scroll-progress { display: block; animation-name: page-scroll-progress; animation-duration: 1ms; animation-timing-function: linear; animation-fill-mode: both; animation-timeline: scroll(root block); } }
The nonzero animation-duration keeps the keyframe animation active, while scroll(root block) maps progress to the page's root block-axis scroll range. If an animation shorthand is used later, set animation-timeline after that shorthand because the shorthand resets it to auto.
@media (prefers-reduced-motion: reduce) { .scroll-progress { animation: none; transform: scaleX(1); } }
This keeps a static accent bar. Use display: none in the reduced-motion branch when the indicator should disappear entirely.
> CSS.supports("animation-timeline: scroll(root block)") true > getComputedStyle(document.querySelector(".scroll-progress")).animationName "page-scroll-progress" > getComputedStyle(document.querySelector(".scroll-progress")).animationTimeline "scroll(root)"
> (() => { const bar = document.querySelector(".scroll-progress"); const matrix = new DOMMatrixReadOnly(getComputedStyle(bar).transform); return { scrollTop: Math.round(document.scrollingElement.scrollTop), maxScroll: document.scrollingElement.scrollHeight - innerHeight, scaleX: Number(matrix.a.toFixed(2)) }; })() {scrollTop: 422, maxScroll: 843, scaleX: 0.5}
The exact scrollTop and maxScroll values depend on page length. The scaleX value should be near 0.5 at the middle of the scroll range and near 1 at the bottom.
> matchMedia("(prefers-reduced-motion: reduce)").matches true > getComputedStyle(document.querySelector(".scroll-progress")).animationName "none" > new DOMMatrixReadOnly(getComputedStyle(document.querySelector(".scroll-progress")).transform).a 1
In Chromium DevTools, use Rendering → Emulate CSS media feature prefers-reduced-motion → reduce.