SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/scroll-progress

7 CSS Scroll Progress Bars

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.

01. Reading Progress Bar at the Top

ScrollProgress

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>
.spb1 { box-sizing: border-box; position: relative; width: 280px; max-width: 100%; border: 1px solid #e4e7ec; border-radius: 14px; background: #fff; overflow: hidden; font-family: Inter, system-ui, sans-serif; }
.spb1 .bar { position: absolute; top: 0; left: 0; right: 0; height: 4px; background: #2563eb; transform: scaleX(0); transform-origin: left; z-index: 1; }
.spb1 article { height: 260px; overflow-y: auto; padding: 16px; box-sizing: border-box; color: #344054; font-size: 14px; line-height: 1.6; scroll-timeline: --spb1 y; }
.spb1 article:focus-visible { outline: 3px solid #84adff; outline-offset: -3px; }
.spb1 h3 { margin: 4px 0 8px; color: #101828; font-size: 17px; }
.spb1 { timeline-scope: --spb1; }
@supports (animation-timeline: scroll()) { .spb1 .bar { animation: spb1 linear both; animation-timeline: --spb1; } @keyframes spb1 { to { transform: scaleX(1); } } }
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>
.spb2 { position: relative; width: 280px; max-width: 100%; font-family: Inter, system-ui, sans-serif; }
.spb2 article { height: 260px; overflow-y: auto; padding: 16px; box-sizing: border-box; border: 1px solid #2a3350; border-radius: 14px; background: #121828; color: #cbd5e1; font-size: 14px; line-height: 1.7; }
.spb2 article:focus-visible { outline: 2px solid #22d3ee; outline-offset: 2px; }
.spb2 h3 { margin: 0 0 8px; color: #fff; }
.spb2 button { --p: 0; position: absolute; right: 12px; bottom: 12px; display: grid; place-items: center; width: 48px; height: 48px; border: 0; border-radius: 50%; background: conic-gradient(#22d3ee calc(var(--p) * 1%), #2a3350 0); cursor: pointer; }
.spb2 button span { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: #0b1020; color: #e0f2fe; font: 700 11px Inter, system-ui, sans-serif; }
.spb2 button:focus-visible { outline: 2px solid #22d3ee; outline-offset: 3px; }
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>
.spb3 { width: 280px; max-width: 100%; border-radius: 14px; overflow: hidden; background: #0f172a; font-family: Inter, system-ui, sans-serif; timeline-scope: --spb3; }
.spb3 .bar { height: 6px; background: linear-gradient(90deg, #22d3ee, #a855f7, #f43f5e); clip-path: inset(0 100% 0 0); }
.spb3 .body { height: 230px; overflow-y: auto; padding: 16px; box-sizing: border-box; color: #cbd5e1; font-size: 14px; scroll-timeline: --spb3 y; }
.spb3 .body:focus-visible { outline: 2px solid #a855f7; outline-offset: -2px; }
.spb3 p { margin: 0 0 12px; }
.spb3 i { display: block; height: 90px; margin-bottom: 12px; border-radius: 10px; background: #1e293b; }
@supports (animation-timeline: scroll()) { .spb3 .bar { animation: spb3 linear both; animation-timeline: --spb3; } @keyframes spb3 { to { clip-path: inset(0 0 0 0); } } }
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>
.spb4 { box-sizing: border-box; width: 290px; max-width: 100%; border: 1px solid #e4e7ec; border-radius: 14px; background: #fff; overflow: hidden; font-family: Inter, system-ui, sans-serif; }
.spb4 nav { display: flex; gap: 4px; padding: 8px; border-bottom: 1px solid #e4e7ec; }
.spb4 nav a { flex: 1; padding-top: 8px; border-top: 4px solid #eaecf0; color: #475467; font-size: 12px; font-weight: 600; text-decoration: none; }
.spb4 nav a[aria-current] { border-top-color: #7f56d9; color: #53389e; }
.spb4 nav a:focus-visible { outline: 2px solid #7f56d9; outline-offset: 2px; }
.spb4 .body { height: 230px; overflow-y: auto; padding: 0 16px; }
.spb4 .body:focus-visible { outline: 3px solid #d6bbfb; outline-offset: -3px; }
.spb4 section { min-height: 200px; padding-top: 14px; }
.spb4 h3 { margin: 0 0 4px; color: #101828; } .spb4 p { margin: 0; color: #475467; font-size: 14px; }
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.

View code
<div class="spb5"><p class="meta"><span class="bar"><i></i></span><b>4 min left</b></p><div class="body" tabindex="0" role="region" aria-label="Article, scroll"></div></div>
.spb5 { box-sizing: border-box; width: 290px; max-width: 100%; border: 1px solid #2a3350; border-radius: 14px; background: #121828; overflow: hidden; font-family: Inter, system-ui, sans-serif; }
.spb5 .meta { display: flex; align-items: center; gap: 10px; margin: 0; padding: 10px 14px; border-bottom: 1px solid #2a3350; color: #e2e8f0; font-size: 12px; }
.spb5 .bar { flex: 1; height: 4px; border-radius: 2px; background: #2a3350; overflow: hidden; }
.spb5 .bar i { display: block; height: 100%; width: 0; background: #f59e0b; }
.spb5 .body { height: 220px; overflow-y: auto; padding: 14px; color: #cbd5e1; font-size: 14px; line-height: 1.6; }
.spb5 .body:focus-visible { outline: 2px solid #f59e0b; outline-offset: -2px; }
.spb5 p.t { margin: 0 0 10px; }
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>
.spb6 { display: grid; grid-template-columns: 6px 1fr; gap: 12px; width: 280px; max-width: 100%; height: 240px; padding: 12px; box-sizing: border-box; border: 1px solid #e4e7ec; border-radius: 14px; background: #fff; font-family: Georgia, serif; timeline-scope: --spb6; }
.spb6 .rail { border-radius: 3px; background: #f2f4f7; overflow: hidden; }
.spb6 .rail i { display: block; height: 100%; background: #db2777; transform: scaleY(0); transform-origin: top; }
.spb6 .body { overflow-y: auto; color: #344054; font-size: 15px; line-height: 1.7; scroll-timeline: --spb6 y; }
.spb6 .body:focus-visible { outline: 3px solid #f9a8d4; outline-offset: 2px; }
.spb6 h3 { margin: 0 0 6px; color: #101828; font-family: Inter, system-ui, sans-serif; }
.spb6 p { margin: 0 0 18px; }
@supports (animation-timeline: scroll()) { .spb6 .rail i { animation: spb6 linear both; animation-timeline: --spb6; } @keyframes spb6 { to { transform: scaleY(1); } } }
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>
.spb7 { width: 280px; max-width: 100%; height: 250px; overflow-y: auto; border-radius: 14px; background: #0f172a; font-family: Inter, system-ui, sans-serif; scroll-timeline: --spb7 y; }
.spb7:focus-visible { outline: 2px solid #34d399; outline-offset: 2px; }
.spb7 header { position: sticky; top: 0; padding: 14px 16px; background: #0f172a linear-gradient(#34d399, #34d399) left bottom / 0% 3px no-repeat; color: #fff; font-weight: 700; font-size: 14px; border-bottom: 3px solid #1e293b; }
.spb7 .c { padding: 10px 16px 16px; color: #cbd5e1; font-size: 14px; }
.spb7 p { margin: 0 0 60px; }
@supports (animation-timeline: scroll()) { .spb7 header { animation: spb7 linear both; animation-timeline: --spb7; } @keyframes spb7 { to { background-size: 100% 3px; } } }
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.