0 Pure CSS7 with JSPublished 2026-10-05Free · no sign-up
Seven ways to show reading progress: a bar across the top, a ring in the corner that doubles as a back-to-top button, a gradient line revealed by clip-path, segments for each section, a bar that says how many minutes are left, a rail beside the text and a header border that fills.
A thin bar across the top fills as the article scrolls. Where CSS scroll timelines are supported, the bar's scaleX is driven by scroll() with no script at all; elsewhere a passive scroll listener sets the same transform.
View code
<div class="spb1"><div class="bar" aria-hidden="true"></div><article tabindex="0" aria-label="Article, scroll"><h3>Why we moved to a four-day week</h3><p>Two years ago we tried an experiment.</p><p>Productivity held, sick days fell and nobody wanted to go back.</p><p>Here is what we learned, what went wrong and what we would change.</p><p>First, meetings. We cut recurring meetings by half before the trial began.</p><p>Second, focus time. Mornings became meeting-free across the company.</p><p>Third, support. Customer hours stayed the same through staggered rotas.</p></article></div>
if (!CSS.supports('animation-timeline: scroll()')) {
var a = document.querySelector('.spb1 article'), bar = document.querySelector('.spb1 .bar');
a.addEventListener('scroll', function () { bar.style.transform = 'scaleX(' + a.scrollTop / (a.scrollHeight - a.clientHeight) + ')'; }, { passive: true });
}
02. Circular Progress with Percentage
ScrollRing
A small ring in the corner fills as you read and shows the percentage in its centre, which doubles as a back-to-top button. The ring is a conic-gradient with a custom property, and the button's name includes the percentage read.
View code
<div class="spb2"><article tabindex="0" aria-label="Article, scroll"><h3>Field guide</h3><p>Scroll through this panel.</p><p>The ring in the corner tracks how far you have read.</p><p>It is also a button that takes you back to the top.</p><p>Paragraphs continue to make the panel scroll.</p><p>Almost there.</p><p>The end.</p></article><button aria-label="Back to top, 0 percent read"><span>0%</span></button></div>
var a = document.querySelector('.spb2 article'), b = document.querySelector('.spb2 button'), t = b.querySelector('span');
a.addEventListener('scroll', function () { var p = Math.round(a.scrollTop / (a.scrollHeight - a.clientHeight) * 100); b.style.setProperty('--p', p); t.textContent = p + '%'; b.setAttribute('aria-label', 'Back to top, ' + p + ' percent read'); }, { passive: true });
b.onclick = function () { a.scrollTo({ top: 0, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); a.focus(); };
03. Gradient Progress Line
ScrollGradient
The progress bar is a full-width gradient revealed by clip-path as you scroll, so its colours stay fixed in place instead of squashing as it grows. The clip is driven by a scroll timeline in supporting browsers.
View code
<div class="spb3"><div class="bar" aria-hidden="true"></div><div class="body" tabindex="0" role="region" aria-label="Scroll this panel"><p>Scroll to fill the gradient line above.</p><i></i><i></i><i></i><i></i></div></div>
if (!CSS.supports('animation-timeline: scroll()')) {
var b = document.querySelector('.spb3 .body'), bar = document.querySelector('.spb3 .bar');
b.addEventListener('scroll', function () { bar.style.clipPath = 'inset(0 ' + (100 - b.scrollTop / (b.scrollHeight - b.clientHeight) * 100) + '% 0 0)'; }, { passive: true });
}
04. Segmented Progress by Section
ScrollSections
The bar is split into one segment per section, and each segment fills as its section is read. It doubles as a table of contents: each segment is a link to its section, named after it, and the current one has aria-current.
View code
<div class="spb4"><nav aria-label="Sections"><a href="#spb4a" aria-current="true">Intro</a><a href="#spb4b">Method</a><a href="#spb4c">Results</a></nav><div class="body" tabindex="0" role="region" aria-label="Report, scroll"><section id="spb4a"><h3>Intro</h3><p>What we set out to test.</p></section><section id="spb4b"><h3>Method</h3><p>Two groups, eight weeks.</p></section><section id="spb4c"><h3>Results</h3><p>Group B shipped 30% faster.</p></section></div></div>
var body = document.querySelector('.spb4 .body'), links = document.querySelectorAll('.spb4 nav a');
var io = new IntersectionObserver(function (es) { es.forEach(function (e) { if (!e.isIntersecting) return; links.forEach(function (l) { if (l.getAttribute('href') === '#' + e.target.id) l.setAttribute('aria-current', 'true'); else l.removeAttribute('aria-current'); }); }); }, { root: body, rootMargin: '0px 0px -70% 0px' });
body.querySelectorAll('section').forEach(function (s) { io.observe(s); });
links.forEach(function (l) { l.onclick = function (e) { e.preventDefault(); var s = body.querySelector(l.getAttribute('href')); body.scrollTo({ top: s.offsetTop - body.offsetTop, behavior: 'smooth' }); }; });
05. Progress with Minutes Left
ScrollReading time
Instead of a percentage, the bar says how many minutes of reading are left, which is more useful to a reader. The estimate comes from the word count at 230 words a minute and updates with the share of the article still below the fold.
var body = document.querySelector('.spb5 .body'), fill = document.querySelector('.spb5 .bar i'), lab = document.querySelector('.spb5 b');
var para = 'Good writing is mostly cutting. Read the draft aloud, delete anything that does not earn its place, and replace vague words with specific ones. ';
for (var i = 0; i < 8; i++) { var p = document.createElement('p'); p.className = 't'; p.textContent = para + para; body.appendChild(p); }
var words = body.textContent.trim().split(/\s+/).length * 4;
function upd() { var f = body.scrollTop / (body.scrollHeight - body.clientHeight || 1); fill.style.width = f * 100 + '%'; var m = Math.ceil(words * (1 - f) / 230); lab.textContent = m > 0 ? m + ' min left' : 'Finished'; }
body.addEventListener('scroll', upd, { passive: true }); upd();
06. Vertical Progress Rail
ScrollVertical
A thin rail beside the text fills downward as you read, a quieter alternative to a top bar for long-form articles. It uses a scroll timeline on scaleY, and the rail sits in its own grid column so it never overlaps the text.
View code
<div class="spb6"><div class="rail" aria-hidden="true"><i></i></div><div class="body" tabindex="0" role="region" aria-label="Essay, scroll"><h3>On slowness</h3><p>Some of the best work happens slowly.</p><p>A garden, a friendship, a well-made chair.</p><p>Speed is useful. It is not the only measure.</p><p>Leave room for the slow parts.</p><p>They are often the ones that last.</p></div></div>
if (!CSS.supports('animation-timeline: scroll()')) {
var b = document.querySelector('.spb6 .body'), i = document.querySelector('.spb6 .rail i');
b.addEventListener('scroll', function () { i.style.transform = 'scaleY(' + b.scrollTop / (b.scrollHeight - b.clientHeight) + ')'; }, { passive: true });
}
07. Header Border as Progress
ScrollHeader
The sticky header's bottom border is the progress bar: a background-size from 0 to 100 percent on a gradient line. It adds progress without adding another element on top of the page, which keeps the header clean.
View code
<div class="spb7" tabindex="0" role="region" aria-label="Page, scroll"><header>Docs · Getting started</header><div class="c"><p>Install the package.</p><p>Import the stylesheet.</p><p>Add the component.</p><p>Configure options.</p><p>Deploy.</p></div></div>
if (!CSS.supports('animation-timeline: scroll()')) {
var b = document.querySelector('.spb7'), h = b.querySelector('header');
b.addEventListener('scroll', function () { h.style.backgroundSize = b.scrollTop / (b.scrollHeight - b.clientHeight) * 100 + '% 3px'; }, { passive: true });
}
About these CSS Scroll Progress Bars
Let CSS drive it
animation-timeline: scroll() links a keyframe animation to scroll position, so a progress bar needs no JavaScript. Where it is unsupported, a passive scroll listener can set the same transform as a fallback.
Scale, do not resize
Animate the bar with transform: scaleX() and transform-origin: left rather than width. Transforms do not trigger layout, so the bar stays smooth while the page scrolls.
Timelines across elements
When the bar is not inside the scrolling element, name the timeline on the scroller (scroll-timeline: --read y) and lift its scope to a shared ancestor with timeline-scope.
Is it worth it?
Progress indicators help on long articles and documentation. Keep them decorative (aria-hidden) unless they do something, like the ring that returns to the top, which then needs a proper accessible name.
Frequently asked questions
Why are these demos inside panels?
So the effect can be tried in a small preview. On a real page, use scroll(root) or omit the scroller name to track the whole document.
Does a progress bar slow down scrolling?
Not when it uses transform or a scroll-driven animation, both of which avoid layout work.
Which browsers support scroll timelines?
Chromium browsers and recent Safari. Each demo includes a script fallback for the others.