The bar in CSS only
The bar is an empty element, fixed to the top of the window. It is full width from the start, and a scaleX from 0 to 1 decides how much of it shows. A transform never makes the browser lay the page out again, where animating width would on every frame of scrolling.
<div class="progress-bar" aria-hidden="true"></div>/* No bar at all where the browser cannot drive it. */
@supports (animation-timeline: scroll()) {
.progress-bar {
position: fixed;
inset: 0 0 auto;
z-index: 1000;
height: 4px;
background: currentColor;
transform-origin: 0 50%; /* grow from the left edge */
transform: scaleX(0); /* empty while there is nothing to scroll */
pointer-events: none;
animation: progress-grow linear both;
animation-timeline: scroll(root block);
}
}
@keyframes progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}scroll(root block) is the page’s own vertical scroll position used as a timeline: 0% at the top, 100% when the window’s bottom reaches the end of the page. The keyframes have no duration, because the scroll sets the pace, and both holds the first frame before the start and the last one after the end. The timeline goes after the animation shorthand, which would reset it. CSS scroll-driven animations explained covers scroll() and view() in general; this post stays on the bar.
Two lines in it are there for the cases where the timeline is missing:
- The
@supportsblock. A browser without scroll timelines ignoresanimation-timelineand plays the keyframes as an ordinary animation that lasts no time, which leaves the bar full from the first pixel. Inside@supports, those browsers get an empty element instead, which shows nothing. transform: scaleX(0)on the bar itself. On a page too short to scroll,scroll(root)has no distance to measure, the timeline is inactive and the animation applies nothing. Without a transform of its own the bar would show full there too.
It runs in Chrome, Edge 115+, Safari 26+ and Firefox 159+. The scroll progress bar generator writes this stylesheet for any edge of the window and any thickness, with a faint track behind the fill if you want one.
Progress through one article, not the whole page
On a blog, the page is more than the post. A header sits above it, and comments, related links and a footer below, so a bar on scroll(root) is still short of full when the reader finishes the last paragraph. To follow the article alone, give it a named view timeline and run the bar on that.
<article>
<div class="article-progress" aria-hidden="true"></div>
<h1>Your article</h1>
<p>…</p>
</article>@supports (view-timeline-name: --article) {
/* The article names its trip through the window… */
article { view-timeline: --article block; }
/* …and the bar inside it runs on that trip. */
.article-progress {
position: fixed;
inset: 0 0 auto;
z-index: 1000;
height: 4px;
background: currentColor;
transform-origin: 0 50%;
transform: scaleX(0);
pointer-events: none;
animation: progress-grow linear both;
animation-timeline: --article;
/* 0 with the article's top at the window's top,
1 with its bottom at the window's bottom. */
animation-range: contain 0% contain 100%;
}
}The @keyframes progress-grow rule is the one from the first stylesheet. The range is what makes it a reading bar. For an article taller than the window, contain 0% is the moment its top reaches the top of the window, and contain 100% the moment its bottom reaches the bottom: from the first line under the top edge to the last line on screen. The default range, cover, would start as soon as the article’s top appeared at the bottom of the window and only finish once it had scrolled off the top, so the bar would be part full before reading began and short of full at the end.
The article has to be taller than the window. A shorter one is wholly in view for a stretch of scrolling, and the bar fills only during that stretch. The generator’s article setting writes this version, bar or ring, and the CSS scroll-driven animation generator lets you try other ranges on a box you scroll, with the CSS beside it.
A circular progress ring
A ring in the corner says the same in less room. It is an SVG circle drawn as a single dash as long as the circle, pushed back by a stroke-dashoffset that shrinks to 0 as the page scrolls. Setting pathLength="100" on the circle saves working out its circumference: whatever its radius, the browser treats it as 100 units long, and the dash and the offset are in those units.
<svg class="progress-ring" width="48" height="48" viewBox="0 0 48 48" aria-hidden="true">
<circle class="progress-ring-track" cx="24" cy="24" r="22" />
<circle class="progress-ring-fill" cx="24" cy="24" r="22" pathLength="100" />
</svg>.progress-ring { display: none; }
@supports (animation-timeline: scroll()) {
.progress-ring {
display: block;
position: fixed;
right: 24px;
bottom: 24px;
z-index: 1000;
rotate: -90deg; /* start at twelve o'clock */
pointer-events: none;
}
.progress-ring circle {
fill: none;
stroke: currentColor;
stroke-width: 4;
}
.progress-ring-track { opacity: 0.15; }
.progress-ring-fill {
stroke-dasharray: 100; /* pathLength="100": one dash, the whole circle */
stroke-dashoffset: 100; /* pushed all the way back: nothing drawn */
animation: ring-fill linear both;
animation-timeline: scroll(root block);
}
}
@keyframes ring-fill {
from { stroke-dashoffset: 100; }
to { stroke-dashoffset: 0; }
}A circle’s stroke starts at three o’clock, so the whole SVG is turned back a quarter to start the ring at the top. The ring is hidden by default here, unlike the bar, because an SVG with no styles is not empty: it would show two filled black circles where scroll timelines are missing. The radius, 22, leaves room for the 4-unit stroke inside the 48-unit box.
A JavaScript fallback for older browsers
Where scroll timelines are missing, a few lines of script can write the same number into a custom property for the bar to read. It is Fig. 1’s arithmetic: scrollY over scrollHeight − innerHeight, held between 0 and 1.
// Only where CSS cannot drive the bar: write the same number from a script.
if (!CSS.supports('animation-timeline: scroll()')) {
const bar = document.querySelector('.progress-bar');
const root = document.documentElement;
let queued = false;
function update() {
queued = false;
const max = root.scrollHeight - innerHeight; // how far the window can travel
const progress = max > 0 ? Math.min(1, Math.max(0, scrollY / max)) : 0;
bar.style.setProperty('--progress', progress);
}
// At most one update a frame, however many scroll events arrive.
function queue() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
bar.classList.add('is-js');
addEventListener('scroll', queue, { passive: true });
addEventListener('resize', queue);
new ResizeObserver(queue).observe(document.body); // images, fonts, late content
update();
}.progress-bar.is-js {
position: fixed;
inset: 0 0 auto;
z-index: 1000;
height: 4px;
background: currentColor;
transform-origin: 0 50%;
transform: scaleX(var(--progress, 0));
pointer-events: none;
}It goes with the same markup and the CSS bar above: the script only runs where the @supports block was skipped, and it styles the bar through its own class, so the two never both apply. The listener is passive, which tells the browser it never cancels the scroll, so scrolling does not wait for it. Scroll events can arrive more often than the screen draws; requestAnimationFrame folds them into one update a frame. The ResizeObserver catches the page getting longer as images and fonts load, which moves the bar without any scroll.
Screen readers and reduced motion
Mark the bar aria-hidden="true", as every listing here does. It is tempting to make it a role="progressbar" with aria-valuenow, but that role is for a task the page is doing, like an upload. Here it would announce a number that changes on every frame of scrolling, or need a script to round it and write it back, and the CSS-only bar has no script at all. A screen-reader user already knows where they are in the article from the headings and the reading position itself, so the bar has nothing to add aloud.
It can also stay for readers who ask for reduced motion. The bar moves only while the reader scrolls, in step with the scroll and as far as it, the same as the scrollbar; it never moves on its own. What does not belong under prefers-reduced-motion: reduce is anything added on top: a transition that makes the bar glide after the scroll has stopped, or a bounce at the end. Put those in a prefers-reduced-motion: no-preference query. Which other scroll effects to turn off is in reduced motion for scroll animations.
The same with a library
svg-scroll-draw’s scrollProgress is the fallback above, in every browser, for any element. It writes the progress, 0 to 1, into --scroll-progress on the element it measures, and the bar’s CSS reads it.
import { scrollProgress } from 'svg-scroll-draw/progress';
// 0 at the top of the page, 1 at the bottom, written to
// --scroll-progress on <html>; the bar's CSS reads it.
scrollProgress(document.documentElement, {
trigger: { start: 'top top', end: 'bottom bottom' },
easedVariable: null, // the bar only reads the raw value
});.progress-bar {
position: fixed;
inset: 0 0 auto;
z-index: 1000;
height: 4px;
background: currentColor;
transform-origin: 0 50%;
transform: scaleX(var(--scroll-progress, 0));
pointer-events: none;
}The trigger reads as element edge, then window edge: progress is 0 when the page’s top is at the window’s top and 1 when its bottom is at the window’s bottom, Fig. 1 again. The default, top bottom to bottom top, runs while any part of the element is on screen, which suits an effect but not a reading bar. The scroll trigger visualizer shows that arithmetic for any pair of edges, with progress running 0 to 1 as you scroll.
For one article, pass the article instead, scrollProgress('article', { … }) with the same trigger, and put the bar inside it so it inherits the property. It follows the scroll for every reader, reduced motion included, measures again when the window or the content changes size, and returns an instance whose destroy() stops it and removes the property. It is its own entry, svg-scroll-draw/progress, at 2.9 KB gzipped.
— / next
Pick an edge, copy the bar
Choose top, bottom, side or a ring, the whole page or one article, scroll the preview, and copy pure CSS or the JavaScript and React code.
Star on GitHubFound this useful? A star helps others find it.