9 Reading Progress Bar CSS Patterns, No Library
A reading progress bar css pattern is any on-page marker that tells a reader how much of a document is already behind them, and these nine put that marker in
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Scroll hint that bounces only on the first screen
- 02 Reading progress that draws around the frame
- 03 Depth badges that light up every quarter
- 04 Focus that keeps only the paragraph you are reading sharp
- 05 Section counter you can move with the keyboard
- 06 Edge shade that says there is more to the side
- 07 Progress written into the browser tab title
- 08 Resume marker that keeps the spot you left
- 09 Ring at the end that pulls in the next article
The nine follow a reader down one page rather than a popularity list. Item 01 fires before anything has scrolled at all. Items 02 to 05 report position while the reading happens, moving from the frame around the article to a counter that answers arrow keys. Item 06 turns the same idea sideways for a table wider than the phone, and 07 hands the number to the browser tab so it survives switching away. The last two deal with coming back (08) and running out of article (09). Each measurement quoted below was read back from that item's own render log: 24 sampled frames of a 480×300 stage, and not one of the nine spilled past its frame at desktop or phone width.
01Scroll hint that bounces only on the first screen
The down arrow rocks 5px on a one-second loop while the article is still parked at the top, and the whole hint leaves on translateY(120%) the moment scrollTop passes 8px. Use it on a landing page whose first screen looks finished, so nobody realizes there is more underneath. Its render logged 19.874% cumulative changed area over 20 moving frames at 47.2, the lowest intensity of the nine, because most of that change is a soft gradient lifting off the text.
.sh__hint {
position: absolute;
left: 0;
right: 0;
bottom: $sp-1;
display: grid;
justify-items: center;
gap: $sp-1;
color: $subject-ink;
isolation: isolate;
&::before {
content: "";
position: absolute;
inset: -$sp-5 0 -$sp-1;
background: linear-gradient(to bottom, transparent, $subject-cream 58%);
z-index: -1;
}
// static path — .is-gone pushes it away once the reader scrolls
transition: transform $dur-quick $easing, opacity $dur-instant linear;
}
.sh.is-gone .sh__hint {
transform: translateY(120%);
opacity: 0;
}
02Reading progress that draws around the frame
Four 4px bars split the article's outline into quarters through animation-range, so the border draws clockwise from the top as the page moves, and the percentage beside it comes from a registered @property printed with counter(). Reach for it on a full-bleed reading screen with no room for a top bar, or on an article set over a photograph. It is the only one of the nine with no time-based preview loop, so its render logged just 6 moving frames, the fewest in the set, yet the highest average intensity at 200.3, because every change is a hard-edged bar rather than a fade.
.pv__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
scroll-timeline-name: --pv-scroll;
scroll-timeline-axis: block;
}
.pv__bar {
position: absolute;
background: $color;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: --pv-scroll;
}
.pv__bar--top {
top: 0; left: 0;
width: 100%; height: 4px;
transform-origin: left center;
transform: scaleX(0);
animation-name: pv-draw-x;
animation-range: 0% 25%;
}
03Depth badges that light up every quarter
Badges for 25, 50, 75 and 100 swap color inside one frame with steps(1, end) as the reader crosses that depth and pop once to scale(1.22), while the bar underneath fills by the same ratio on scaleX. Use it where finishing is the point: a notice that pays out a coupon at the end, or an in-house training document that has to measure read depth. Ten separate animations run in this item, more than in any other of the nine, and 22 of the 24 sampled frames registered movement.
.db__chip {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(23, 20, 26, .1);
color: rgba(23, 20, 26, .45);
// static path — the color cuts the instant .is-on lands
transition: background $dur-instant steps(1, end), color $dur-instant steps(1, end);
}
.db__chip.is-on {
background: $color;
color: #fff;
animation-name: db-pop;
animation-duration: $dur-base;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes db-pop {
0% { transform: scale(1); }
45% { transform: scale(1.22); }
100% { transform: scale(1); }
}
04Focus that keeps only the paragraph you are reading sharp
Every paragraph sits at opacity: .35 until an IntersectionObserver running rootMargin: '-45% 0px -45% 0px' finds the one crossing the middle tenth of the window, which then takes aria-current="true" and full visual weight. It suits long contracts, terms pages, and scripts read aloud, where losing your line costs real time. The preview stages the same drift with five animation-delay steps 0.4s apart inside a 2s loop, and it moved in 23 of 24 frames, the steadiest render in the set.
.rf__p {
margin: 0;
font-size: 11px;
line-height: 1.6;
color: $subject-ink;
// static path — only the .is-cur paragraph stays sharp
opacity: .35;
transition: opacity $dur-quick $easing;
}
.rf__p.is-cur,
.rf__p[aria-current="true"] {
opacity: 1;
font-weight: 700;
}
@keyframes rf-focus {
0% { opacity: .35; }
6% { opacity: 1; }
14% { opacity: 1; }
20%, 100% { opacity: .35; }
}
05Section counter you can move with the keyboard
The "3 / 9" readout changes as each 128px scene snaps into place and aria-current travels with it, while ArrowDown and ArrowUp call scrollTo with the next scene's offsetTop and behavior: 'smooth'. Pick it for a deck that turns one scene per screen, or a step-by-step explainer where people need to step back exactly one stop. Because a full scene slides at once, it posts the largest cumulative changed area of the nine, 25.274%, across only 10 moving frames.
function goTo(i) {
sc.classList.remove('is-demo');
i = Math.max(0, Math.min(secs.length - 1, i));
view.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' });
}
view.addEventListener('scroll', function () { sc.classList.remove('is-demo'); paint(); }, { passive: true });
view.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); goTo(currentIndex() + 1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goTo(currentIndex() - 1); }
});
document.getElementById('sc-up').addEventListener('click', function () { goTo(currentIndex() - 1); });
document.getElementById('sc-down').addEventListener('click', function () { goTo(currentIndex() + 1); });
06Edge shade that says there is more to the side
A 32px linear-gradient shade lies over each end of the strip, and the script switches one off with .is-off as soon as scrollLeft comes within 4px of that edge, so a visible shade always means unread content. Use it on a pricing table swiped sideways on a phone, or on any statistics table with more columns than the screen. The small arrow riding the right-hand shade nudges 4px and returns twice per loop, which keeps the whole item down to 68.3 average intensity over 15 moving frames.
.ot__shade {
position: absolute;
top: 32px;
bottom: 30px;
width: 32px;
pointer-events: none;
opacity: 1;
// static path — reaching an end switches that side off
transition: opacity $dur-quick $easing;
}
.ot__shade--l {
left: $sp-2;
background: linear-gradient(to right, $subject-cream, rgba(255, 247, 230, 0));
}
.ot__shade--r {
right: $sp-2;
background: linear-gradient(to left, $subject-cream, rgba(255, 247, 230, 0));
display: grid;
align-items: end;
justify-items: center;
}
.ot__shade.is-off { opacity: 0; }
07Progress written into the browser tab title
document.title gets the percentage stamped on the front, so a reader with a row of tabs open finds their place without switching to the page at all, and the ring in the tab icon slot fills through a conic-gradient whose stop is driven by a @property-registered --p set on the ring element itself. It belongs on long documents read across several tabs and on internal wikis. Only 0.849% of the frame changes across the whole loop, the quietest render of the nine, although 22 of 24 frames still catch the ring creeping forward.
// the ring's fill angle — a registered custom property interpolates on the element itself
@property --p {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.tt__ring {
--p: 0;
position: relative;
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
color: $subject-ink;
font-size: 12px;
background: conic-gradient(from -90deg, $subject-blue calc(var(--p) * 360deg), rgba(23, 20, 26, .18) 0);
}
08Resume marker that keeps the spot you left
A 2px dashed line stays pinned at the deepest scrollTop the reader ever reached, and a pill rises on translateY only while that line is genuinely out of view, which needs scrollTop + clientHeight < deepest + 8 and scrollTop < deepest - 8 to hold together. Use it on help pages people abandon halfway, or on serials with a long back catalog. It runs on four animations, the fewest in the set alongside 09, and still moves 24.428% of the frame because a whole column of text travels 28% of its own height.
.rs__view {
box-sizing: border-box;
height: 148px;
margin: $sp-2 $sp-2 0;
padding-right: $sp-2;
overflow-y: auto;
scrollbar-width: none;
}
.rs__pad {
position: relative;
display: grid;
gap: $sp-2;
// the bottom slack has to beat the 148px window, or the marker can never be pushed out of sight
padding: $sp-1 $sp-1 160px;
}
.rs__mark {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0;
border-top: 2px dashed $color;
pointer-events: none;
}
09Ring at the end that pulls in the next article
Past the last paragraph the ring's stroke-dashoffset unwinds from a 263.9 circumference, mapped so that the final 45% of the scroll range drives the entire circle, and the label cuts to "Next article" in one frame with steps(1, end) once the fill clears 95%. Use it on a media site that hands you the next read, or on a course list that runs in sequence. The circle is drawn on a 96-unit viewBox at r="42", so 2 * Math.PI * 42 is the exact dash length the script writes back, and the loop moved in 15 of 24 frames.
var circumference = 2 * Math.PI * 42;
fill.style.strokeDasharray = circumference;
view.addEventListener('scroll', function () {
nx.classList.remove('is-demo');
var max = view.scrollHeight - view.clientHeight;
var r = max > 0 ? view.scrollTop / max : 0;
var p = Math.max(0, Math.min(1, (r - 0.55) / 0.45));
fill.style.strokeDashoffset = circumference * (1 - p);
role.setAttribute('aria-valuenow', Math.round(p * 100));
var isNext = p >= 0.95;
nx.classList.toggle('nx--next', isNext);
});
Where it breaks — the trap
The first wall is vertical. The phone render is 320×200, and once the stage's own 25.6px of padding is gone there are 174px left for the entire component. Three of the nine had to give ground inside @media (max-width: 340px): the badge row in 03 eats 56px under a 24px header, so its reading window drops from 132px to 84px; 05 spends 31px on the header and 48px on the key row and lands on the same 84px; 09 keeps a 128px window after 22px of header and 20px of margin. Every one of those numbers went down, never up. The reflex when a screen narrows is to stack things, and stacking spends height you do not have in order to buy width you already had. In 05 the arithmetic reaches into the preview loop as well, because when the scene height changes from 128px to 84px the loop's own travel has to be rewritten with it, from −256, −640 and −1024px to −168, −420 and −672px, which is 84 multiplied by 2, 5 and 8.
The second break only appears after the preview loop is taken away. Item 07 stacks four tab titles in the same 14px slot and cuts between them, and the first build hid all four with display: none and lit them from the loop alone. Remove .is-demo, which is exactly what the scroll handler does the first time a real person touches the demo, and the tab label would go blank, because nothing outside the loop had ever made a title visible. Now .tt__t--0 { display: block; } lives outside the loop and the scroll handler rewrites that one element with tabLabel.textContent = now > 0 ? now + '% · ' + tabName : tabName. A preview that cannot survive its own class being removed is a picture, not a component.
The third is a condition that arithmetic can make unreachable. The pill in 08 shows up only while the dashed line has been pushed out of a window that is 148px tall. If the slack under the last paragraph were shorter than that window, no amount of scrolling could ever push the line out, and the condition would be false forever no matter how carefully the handler was written. .rs__pad now carries a bottom padding of 160px, twelve pixels more than the window, and only at that point does pushedOut become something that can happen.
The fourth decides which frame becomes the thumbnail. Two seconds sampled 24 times is one sample every 4.1667%, so a loop that resets between two samples gets photographed mid-reset. Item 09 records the reason in its keyframe comments: without a gap, the frame picked as the poster is one where the ring is empty while the label already reads "Next article", a contradiction inside a single still. The reset now sits between the 87.5% and 91.67% samples, with nx-draw holding stroke-dashoffset: 0 from 78% to 88% and jumping back at 90% while nx-l0 returns at 89%, and 07 puts tt-fill and its four titles in the same 88 to 90% band. Items 03, 05 and 08 reset inside that window too.
The fifth is the newest CSS in the set. Item 02 puts scroll-timeline-name: --pv-scroll on the scrollport and points all four bars and the counter at that name rather than at an anonymous animation-timeline: scroll(), because an anonymous timeline attaches to the nearest ancestor scroller and an overflow: hidden box standing in between will quietly take the job. Naming it is also what lets the number and the four edges come from a single source, so a drawn border sitting next to a 0% readout cannot happen. Only 02 and 07 lean on that layer, which is why the archive password is exsgeatr and why the other seven files in it need nothing newer than a scroll listener.
| Feature | Items using it | Chrome / Edge | Safari | Firefox |
|---|---|---|---|---|
animation-timeline |
02 | 115 | 26 | 158 |
@property |
02, 07 | 85 | 16.4 | 128 |
Scroll events, IntersectionObserver, scrollTo |
01, 03, 04, 05, 06, 08, 09 | long-standing | long-standing | long-standing |
Those first two rows are worth reading before you paste 02 anywhere: caniuse puts animation-timeline at 87% global support and names Firefox 158 as the first version to carry it, while @property has been in Safari since 16.4 and Firefox since 128. Seven of the nine were deliberately built without either.
Accessibility (reduced-motion)
Each item answers prefers-reduced-motion: reduce by stopping the motion and freezing on a pose that still carries the meaning. In 01 the arrow, the text flow, and the header cut stop while the hint itself is forced to opacity: 1 with transform: none, so you can still read what it says. In 02 the four bars stop with the top edge held at scaleX(1) and the other three at zero, and the counter is left alone on purpose, since it only ever moves when the reader moves. In 03 the chips, the fill, and the live line stop, and 25 and 50 are forced on with the bar pinned at scaleX(.5) so the idea of depth survives. In 04 the paragraph fades, the rail dot and the header all stop, and the third paragraph keeps full opacity and bold weight so "this is the current one" still reads. In 05 the track, the numbers, and the scene cuts stop, leaving the first scene highlighted and the counter showing 1. In 06 the slide, the arrow nudge, and the header cut stop, with the left shade forced off and the right one on, which is exactly what a strip parked at its left end should look like. In 07 the ring and the titles stop, the ring held at --p: .5 and the plain title shown. In 08 the flow, the pill, and the header stop, and the pad is held at translateY(-14%) with the dashed line at top: 96px and the pill fully up, so all three parts of the pattern are on screen at once. In 09 the fill, the labels, and the header stop with stroke-dashoffset held at 132, half of the 263.9 circumference, beside the first label.
Every one of those blocks needs !important. The preview rules are written as compound selectors such as .rs.is-demo .rs__pad, which outrank a plain .rs__pad inside a media query, and specificity does not care that one rule sits in @media (prefers-reduced-motion: reduce) and the other does not. The full rules for honoring that query are in MDN's prefers-reduced-motion reference.
The markup is worth checking for yourself rather than taking it on faith. Four of the nine expose a real role="progressbar" with aria-valuemin, aria-valuemax, and a live aria-valuenow: 02 on the article frame, 03 on the badge row, 07 on the tab bar, and 09 on the pull target. Two carry an aria-live="polite" region, 03 for each newly lit depth and 08 for the resume wording. Two move aria-current="true" between elements, 04 across paragraphs and 05 across scenes, and both style the current element from that attribute rather than from a class alone. All nine make their scrollport a labeled role="region" with tabindex="0", and in 02 the progress role sits on the article frame inside that scroller rather than on the scroller itself. All nine define a :focus-visible outline, and the only real controls in the set are buttons: the two arrow buttons in 05 and the resume pill in 08.
A single top-bar version of the same idea, measured the same way, sits in the scroll progress indicator piece; the rest of the scroll-triggered set is filed under trigger and scroll, and the about page explains the render-and-measure step every demo here passes before it ships.
FAQ
Do any of these run with no JavaScript at all?
Item 02 does. Its four edges and its percentage both come from scroll-timeline-name and a registered @property, so the border draws and the number counts with the stylesheet alone. The other eight read scrollTop, scrollLeft, or an IntersectionObserver entry, so they need a handful of lines, but every visible state in them is a CSS class you can drive from your own framework.
Why count the percentage in 02 with @property instead of writing text from script?
Because a number written by script and a border drawn by CSS are two separate clocks, and they drift. Registering --pv-pct as an <integer> lets the same scroll timeline animate it and counter() print it, so the count and the four edges cannot disagree even for one frame. A two-line scroll listener still mirrors the value into aria-valuenow, which is the part a screen reader actually receives.
How do I move one of these onto a real page instead of a boxed demo?
Each demo scrolls an inner element so it can sit on a preview canvas. Swap that scrollport for document.documentElement, read scrollY and document.body.scrollHeight in place of scrollTop and scrollHeight, and drop the .stage wrapper that centers it. For 02 specifically, move scroll-timeline-name onto :root and delete the fixed height, because the page itself becomes the scroller.