18 CSS Scroll Progress Bar04 / 18
Scroll Progress Bar with Percentage Counter Tooltip
A progress bar with a live numeric readout that rides along the fill — and the number itself is generated by CSS, not JavaScript. Registering an <integer> custom property with @property makes it animatable, and counter() turns that animated integer into rendered text. The result is a scroll percentage with zero string concatenation on any frame.
Published
The code
<section class="sp-04" aria-label="Scroll progress bar with percentage tooltip demo">
<div class="sp-04__rail" aria-hidden="true">
<div class="sp-04__track"><i class="sp-04__fill"></i></div>
<div class="sp-04__tipwrap"><span class="sp-04__tip" id="sp-04-tip"></span></div>
</div>
<p class="sp-04__sr" role="progressbar" aria-label="Page read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="sp-04-sr">0% read</p>
<header class="sp-04__bar">
<a class="sp-04__brand" href="#sp-04-s1"><span class="sp-04__logo" aria-hidden="true"></span>Counter</a>
<p class="sp-04__meta">@property · counter()</p>
</header>
<div class="sp-04__intro">
<div class="sp-04__introin">
<p class="sp-04__intro-eyebrow">counter-reset: sp var(--n)</p>
<h2 class="sp-04__intro-title">CSS can count. Let it.</h2>
<p class="sp-04__intro-sub">The bubble riding the bar is generated content — an animated registered integer printed through a CSS counter. No template string is built on any frame.</p>
</div>
</div>
<main class="sp-04__main">
<section class="sp-04__sec" id="sp-04-s1">
<p class="sp-04__kicker">Registration</p>
<h3 class="sp-04__h">Why @property is the whole trick</h3>
<p class="sp-04__p sp-04__p--lede">An unregistered custom property is an opaque token stream. The engine has no idea 0 and 100 are numbers, so it cannot interpolate — it just swaps at the halfway point.</p>
<p class="sp-04__p">Register it with <code>syntax:'<integer>'</code> and the same declaration becomes a first-class animatable value.</p>
</section>
<section class="sp-04__sec" id="sp-04-s2">
<p class="sp-04__kicker">Rendering</p>
<h3 class="sp-04__h">counter() is the only way out</h3>
<p class="sp-04__p">There is no <code>content: var(--n)</code> that prints a number. Feeding the value into <code>counter-reset</code> and printing it with <code>counter()</code> is the sanctioned route, and it has worked since CSS 2.1.</p>
</section>
<section class="sp-04__sec" id="sp-04-s3">
<p class="sp-04__kicker">Tracking</p>
<h3 class="sp-04__h">One timeline, two animations</h3>
<p class="sp-04__p">The fill and the bubble share <code>scroll(root block)</code>. Two JavaScript updates throttled independently will eventually disagree by a frame; two animations on one timeline cannot.</p>
<figure class="sp-04__fig">
<img class="sp-04__img" src="https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=1400&q=70" alt="A laptop showing code on a desk beside a plant" loading="lazy" decoding="async">
<figcaption class="sp-04__cap">Shared timelines are the scroll-driven equivalent of a single source of truth.</figcaption>
</figure>
</section>
<section class="sp-04__sec" id="sp-04-s4">
<p class="sp-04__kicker">Accessibility</p>
<h3 class="sp-04__h">Generated content is not an announcement</h3>
<p class="sp-04__p">Text drawn by <code>::after</code> is inconsistently exposed and never selectable. A real <code>progressbar</code> element with a throttled <code>aria-valuenow</code> carries the meaning.</p>
<p class="sp-04__chip">0 string builds per frame</p>
</section>
</main>
<footer class="sp-04__pagefoot"><p class="sp-04__pagefootin">Scroll progress pattern 04 of 18 · codefronts.com</p></footer>
</section><section class="sp-04" aria-label="Scroll progress bar with percentage tooltip demo">
<div class="sp-04__rail" aria-hidden="true">
<div class="sp-04__track"><i class="sp-04__fill"></i></div>
<div class="sp-04__tipwrap"><span class="sp-04__tip" id="sp-04-tip"></span></div>
</div>
<p class="sp-04__sr" role="progressbar" aria-label="Page read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="sp-04-sr">0% read</p>
<header class="sp-04__bar">
<a class="sp-04__brand" href="#sp-04-s1"><span class="sp-04__logo" aria-hidden="true"></span>Counter</a>
<p class="sp-04__meta">@property · counter()</p>
</header>
<div class="sp-04__intro">
<div class="sp-04__introin">
<p class="sp-04__intro-eyebrow">counter-reset: sp var(--n)</p>
<h2 class="sp-04__intro-title">CSS can count. Let it.</h2>
<p class="sp-04__intro-sub">The bubble riding the bar is generated content — an animated registered integer printed through a CSS counter. No template string is built on any frame.</p>
</div>
</div>
<main class="sp-04__main">
<section class="sp-04__sec" id="sp-04-s1">
<p class="sp-04__kicker">Registration</p>
<h3 class="sp-04__h">Why @property is the whole trick</h3>
<p class="sp-04__p sp-04__p--lede">An unregistered custom property is an opaque token stream. The engine has no idea 0 and 100 are numbers, so it cannot interpolate — it just swaps at the halfway point.</p>
<p class="sp-04__p">Register it with <code>syntax:'<integer>'</code> and the same declaration becomes a first-class animatable value.</p>
</section>
<section class="sp-04__sec" id="sp-04-s2">
<p class="sp-04__kicker">Rendering</p>
<h3 class="sp-04__h">counter() is the only way out</h3>
<p class="sp-04__p">There is no <code>content: var(--n)</code> that prints a number. Feeding the value into <code>counter-reset</code> and printing it with <code>counter()</code> is the sanctioned route, and it has worked since CSS 2.1.</p>
</section>
<section class="sp-04__sec" id="sp-04-s3">
<p class="sp-04__kicker">Tracking</p>
<h3 class="sp-04__h">One timeline, two animations</h3>
<p class="sp-04__p">The fill and the bubble share <code>scroll(root block)</code>. Two JavaScript updates throttled independently will eventually disagree by a frame; two animations on one timeline cannot.</p>
<figure class="sp-04__fig">
<img class="sp-04__img" src="https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=1400&q=70" alt="A laptop showing code on a desk beside a plant" loading="lazy" decoding="async">
<figcaption class="sp-04__cap">Shared timelines are the scroll-driven equivalent of a single source of truth.</figcaption>
</figure>
</section>
<section class="sp-04__sec" id="sp-04-s4">
<p class="sp-04__kicker">Accessibility</p>
<h3 class="sp-04__h">Generated content is not an announcement</h3>
<p class="sp-04__p">Text drawn by <code>::after</code> is inconsistently exposed and never selectable. A real <code>progressbar</code> element with a throttled <code>aria-valuenow</code> carries the meaning.</p>
<p class="sp-04__chip">0 string builds per frame</p>
</section>
</main>
<footer class="sp-04__pagefoot"><p class="sp-04__pagefootin">Scroll progress pattern 04 of 18 · codefronts.com</p></footer>
</section>@property --sp-04-n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --sp-04-p {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.sp-04 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--sp-04-bg);
--sp-04-bg: oklch(0.15 0.02 285);
--sp-04-surface: oklch(0.2 0.025 285);
--sp-04-ink: oklch(0.97 0.005 285);
--sp-04-mut: oklch(0.72 0.02 285);
--sp-04-acc: oklch(0.78 0.17 85);
--sp-04-acc2: oklch(0.7 0.19 30);
--sp-04-line: oklch(1 0 0/.12);
--sp-04-h: 6px;
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--sp-04-ink);
-webkit-font-smoothing: antialiased;
}
.sp-04 *,
.sp-04 *::before,
.sp-04 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.sp-04__rail {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
z-index: 60;
pointer-events: none;
}
.sp-04__track {
block-size: var(--sp-04-h);
background: oklch(1 0 0/.09);
}
.sp-04__fill {
display: block;
block-size: 100%;
transform-origin: 0 50%;
transform: scaleX(var(--sp-04-p));
background: linear-gradient(90deg,var(--sp-04-acc2),var(--sp-04-acc));
box-shadow: 0 0 16px -2px var(--sp-04-acc);
}
.sp-04__tipwrap {
position: relative;
block-size: 0;
margin-inline: 32px;
}
.sp-04__tip {
position: absolute;
inset-block-start: 6px;
inset-inline-start: 0;
translate: calc(var(--sp-04-p) * 100 * 1%) 0;
transform: translateX(-50%);
counter-reset: sp04 var(--sp-04-n);
display: block;
padding: 4px 9px;
border-radius: 8px;
background: var(--sp-04-acc);
color: oklch(0.18 0.03 85);
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
box-shadow: 0 6px 18px -8px oklch(0 0 0/.8);
}
.sp-04__tip::after {
content: counter(sp04) "%";
}
.sp-04__tip::before {
content: "";
position: absolute;
inset-block-start: -3px;
inset-inline-start: 50%;
inline-size: 7px;
block-size: 7px;
background: inherit;
transform: translateX(-50%) rotate(45deg);
border-radius: 1px;
}
@supports (animation-timeline: scroll()) {
.sp-04__fill {
transform: scaleX(0);
animation: sp-04-fill linear both;
animation-timeline: scroll(root block);
}
.sp-04__tip {
animation: sp-04-count linear both;
animation-timeline: scroll(root block);
}
}
@keyframes sp-04-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes sp-04-count {
from {
--sp-04-n: 0;
translate: 0 0;
}
to {
--sp-04-n: 100;
translate: 100% 0;
}
}
.sp-04__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.sp-04__bar {
position: sticky;
inset-block-start: 0;
z-index: 40;
display: flex;
align-items: flex-end;
gap: 14px;
min-block-size: 80px;
padding-block: calc(var(--sp-04-h) + 26px) 12px;
background: color-mix(in oklch,var(--sp-04-bg) 72%,transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-block-end: 1px solid var(--sp-04-line);
}
.sp-04__brand {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 730;
letter-spacing: -.02em;
color: inherit;
text-decoration: none;
}
.sp-04__brand:focus-visible {
outline: 2px solid var(--sp-04-acc);
outline-offset: 3px;
border-radius: 6px;
}
.sp-04__logo {
inline-size: 18px;
block-size: 18px;
border-radius: 5px;
background: linear-gradient(140deg,var(--sp-04-acc),var(--sp-04-acc2));
}
.sp-04__meta {
margin-inline-start: auto;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--sp-04-mut);
}
.sp-04__intro {
display: grid;
place-items: center;
min-block-size: min(86svh,820px);
padding-block: clamp(56px,12vh,140px) clamp(40px,8vh,90px);
background: radial-gradient(90% 60% at 50% 0%,oklch(0.28 0.07 60),transparent);
}
.sp-04__introin {
display: grid;
justify-items: center;
gap: clamp(12px,1.8vw,18px);
text-align: center;
max-inline-size: 26ch;
}
.sp-04__intro-eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: clamp(11px,1.1vw,13px);
letter-spacing: .12em;
text-transform: uppercase;
color: var(--sp-04-acc);
}
.sp-04__intro-title {
font-size: clamp(38px,7.4vw,84px);
line-height: .98;
letter-spacing: -.04em;
font-weight: 730;
text-wrap: balance;
}
.sp-04__intro-sub {
font-size: clamp(15px,1.7vw,19px);
line-height: 1.55;
color: var(--sp-04-mut);
max-inline-size: 48ch;
text-wrap: pretty;
}
.sp-04__sec {
scroll-margin-top: 96px;
display: grid;
gap: 12px;
align-content: center;
min-block-size: 72svh;
padding-block: clamp(28px,5vw,56px);
border-block-end: 1px solid var(--sp-04-line);
}
.sp-04__sec:last-child {
border-block-end: 0;
}
.sp-04__kicker {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--sp-04-acc);
}
.sp-04__h {
font-size: clamp(22px,3.2vw,34px);
line-height: 1.1;
letter-spacing: -.032em;
font-weight: 700;
text-wrap: balance;
}
.sp-04__p {
max-inline-size: 60ch;
font-size: 15.8px;
line-height: 1.7;
color: var(--sp-04-mut);
text-wrap: pretty;
}
.sp-04__p--lede {
font-size: 18px;
color: var(--sp-04-ink);
}
.sp-04__p code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .85em;
background: oklch(1 0 0/.1);
padding: 2px 6px;
border-radius: 5px;
}
.sp-04__fig {
display: grid;
gap: 9px;
max-inline-size: 70ch;
}
.sp-04__img {
inline-size: 100%;
aspect-ratio: 16/7;
object-fit: cover;
display: block;
border-radius: 16px;
background: linear-gradient(135deg,oklch(0.4 0.09 60),oklch(0.25 0.05 300));
}
.sp-04__cap {
font-size: 12.6px;
color: var(--sp-04-mut);
}
.sp-04__chip {
justify-self: start;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--sp-04-acc);
padding: 7px 13px;
border-radius: 99px;
background: color-mix(in oklch,var(--sp-04-acc) 14%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-04-acc) 30%,transparent);
}
.sp-04__pagefoot {
display: grid;
place-items: center;
padding: clamp(44px,9vh,110px) 24px;
border-block-start: 1px solid var(--sp-04-line);
}
.sp-04__pagefootin {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
letter-spacing: .06em;
color: var(--sp-04-mut);
text-align: center;
}
.sp-04__bar {
padding-inline: max(clamp(16px,3vw,26px),calc((100% - 1180px)/2));
}
.sp-04__intro,
.sp-04__sec {
padding-inline: max(clamp(22px,5vw,44px),calc((100% - 900px)/2));
}
:root:has(.sp-04) {
scroll-behavior: smooth;
scroll-padding-top: 96px;
}
@media (max-width: 640px) {
.sp-04__sec {
min-block-size: auto;
}
}
@media (prefers-reduced-motion: reduce) {
:root:has(.sp-04) {
scroll-behavior: auto;
}
.sp-04__fill {
box-shadow: none;
}
.sp-04 * {
transition-duration: .01ms !important;
}
}@property --sp-04-n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --sp-04-p {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.sp-04 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--sp-04-bg);
--sp-04-bg: oklch(0.15 0.02 285);
--sp-04-surface: oklch(0.2 0.025 285);
--sp-04-ink: oklch(0.97 0.005 285);
--sp-04-mut: oklch(0.72 0.02 285);
--sp-04-acc: oklch(0.78 0.17 85);
--sp-04-acc2: oklch(0.7 0.19 30);
--sp-04-line: oklch(1 0 0/.12);
--sp-04-h: 6px;
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--sp-04-ink);
-webkit-font-smoothing: antialiased;
}
.sp-04 *,
.sp-04 *::before,
.sp-04 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.sp-04__rail {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
z-index: 60;
pointer-events: none;
}
.sp-04__track {
block-size: var(--sp-04-h);
background: oklch(1 0 0/.09);
}
.sp-04__fill {
display: block;
block-size: 100%;
transform-origin: 0 50%;
transform: scaleX(var(--sp-04-p));
background: linear-gradient(90deg,var(--sp-04-acc2),var(--sp-04-acc));
box-shadow: 0 0 16px -2px var(--sp-04-acc);
}
.sp-04__tipwrap {
position: relative;
block-size: 0;
margin-inline: 32px;
}
.sp-04__tip {
position: absolute;
inset-block-start: 6px;
inset-inline-start: 0;
translate: calc(var(--sp-04-p) * 100 * 1%) 0;
transform: translateX(-50%);
counter-reset: sp04 var(--sp-04-n);
display: block;
padding: 4px 9px;
border-radius: 8px;
background: var(--sp-04-acc);
color: oklch(0.18 0.03 85);
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
box-shadow: 0 6px 18px -8px oklch(0 0 0/.8);
}
.sp-04__tip::after {
content: counter(sp04) "%";
}
.sp-04__tip::before {
content: "";
position: absolute;
inset-block-start: -3px;
inset-inline-start: 50%;
inline-size: 7px;
block-size: 7px;
background: inherit;
transform: translateX(-50%) rotate(45deg);
border-radius: 1px;
}
@supports (animation-timeline: scroll()) {
.sp-04__fill {
transform: scaleX(0);
animation: sp-04-fill linear both;
animation-timeline: scroll(root block);
}
.sp-04__tip {
animation: sp-04-count linear both;
animation-timeline: scroll(root block);
}
}
@keyframes sp-04-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes sp-04-count {
from {
--sp-04-n: 0;
translate: 0 0;
}
to {
--sp-04-n: 100;
translate: 100% 0;
}
}
.sp-04__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.sp-04__bar {
position: sticky;
inset-block-start: 0;
z-index: 40;
display: flex;
align-items: flex-end;
gap: 14px;
min-block-size: 80px;
padding-block: calc(var(--sp-04-h) + 26px) 12px;
background: color-mix(in oklch,var(--sp-04-bg) 72%,transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-block-end: 1px solid var(--sp-04-line);
}
.sp-04__brand {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 730;
letter-spacing: -.02em;
color: inherit;
text-decoration: none;
}
.sp-04__brand:focus-visible {
outline: 2px solid var(--sp-04-acc);
outline-offset: 3px;
border-radius: 6px;
}
.sp-04__logo {
inline-size: 18px;
block-size: 18px;
border-radius: 5px;
background: linear-gradient(140deg,var(--sp-04-acc),var(--sp-04-acc2));
}
.sp-04__meta {
margin-inline-start: auto;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--sp-04-mut);
}
.sp-04__intro {
display: grid;
place-items: center;
min-block-size: min(86svh,820px);
padding-block: clamp(56px,12vh,140px) clamp(40px,8vh,90px);
background: radial-gradient(90% 60% at 50% 0%,oklch(0.28 0.07 60),transparent);
}
.sp-04__introin {
display: grid;
justify-items: center;
gap: clamp(12px,1.8vw,18px);
text-align: center;
max-inline-size: 26ch;
}
.sp-04__intro-eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: clamp(11px,1.1vw,13px);
letter-spacing: .12em;
text-transform: uppercase;
color: var(--sp-04-acc);
}
.sp-04__intro-title {
font-size: clamp(38px,7.4vw,84px);
line-height: .98;
letter-spacing: -.04em;
font-weight: 730;
text-wrap: balance;
}
.sp-04__intro-sub {
font-size: clamp(15px,1.7vw,19px);
line-height: 1.55;
color: var(--sp-04-mut);
max-inline-size: 48ch;
text-wrap: pretty;
}
.sp-04__sec {
scroll-margin-top: 96px;
display: grid;
gap: 12px;
align-content: center;
min-block-size: 72svh;
padding-block: clamp(28px,5vw,56px);
border-block-end: 1px solid var(--sp-04-line);
}
.sp-04__sec:last-child {
border-block-end: 0;
}
.sp-04__kicker {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--sp-04-acc);
}
.sp-04__h {
font-size: clamp(22px,3.2vw,34px);
line-height: 1.1;
letter-spacing: -.032em;
font-weight: 700;
text-wrap: balance;
}
.sp-04__p {
max-inline-size: 60ch;
font-size: 15.8px;
line-height: 1.7;
color: var(--sp-04-mut);
text-wrap: pretty;
}
.sp-04__p--lede {
font-size: 18px;
color: var(--sp-04-ink);
}
.sp-04__p code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .85em;
background: oklch(1 0 0/.1);
padding: 2px 6px;
border-radius: 5px;
}
.sp-04__fig {
display: grid;
gap: 9px;
max-inline-size: 70ch;
}
.sp-04__img {
inline-size: 100%;
aspect-ratio: 16/7;
object-fit: cover;
display: block;
border-radius: 16px;
background: linear-gradient(135deg,oklch(0.4 0.09 60),oklch(0.25 0.05 300));
}
.sp-04__cap {
font-size: 12.6px;
color: var(--sp-04-mut);
}
.sp-04__chip {
justify-self: start;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--sp-04-acc);
padding: 7px 13px;
border-radius: 99px;
background: color-mix(in oklch,var(--sp-04-acc) 14%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-04-acc) 30%,transparent);
}
.sp-04__pagefoot {
display: grid;
place-items: center;
padding: clamp(44px,9vh,110px) 24px;
border-block-start: 1px solid var(--sp-04-line);
}
.sp-04__pagefootin {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
letter-spacing: .06em;
color: var(--sp-04-mut);
text-align: center;
}
.sp-04__bar {
padding-inline: max(clamp(16px,3vw,26px),calc((100% - 1180px)/2));
}
.sp-04__intro,
.sp-04__sec {
padding-inline: max(clamp(22px,5vw,44px),calc((100% - 900px)/2));
}
:root:has(.sp-04) {
scroll-behavior: smooth;
scroll-padding-top: 96px;
}
@media (max-width: 640px) {
.sp-04__sec {
min-block-size: auto;
}
}
@media (prefers-reduced-motion: reduce) {
:root:has(.sp-04) {
scroll-behavior: auto;
}
.sp-04__fill {
box-shadow: none;
}
.sp-04 * {
transition-duration: .01ms !important;
}
}(() => {
const root = document.querySelector('.sp-04');
if (!root || root.dataset.spWired) return;
root.dataset.spWired = '1';
const sr = root.querySelector('#sp-04-sr');
const tip = root.querySelector('#sp-04-tip');
const native = CSS.supports('animation-timeline', 'scroll()');
let ticking = false, last = -1;
const paint = () => {
ticking = false;
const d = document.documentElement;
const max = d.scrollHeight - d.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, d.scrollTop / max)) : 0;
const n = Math.round(p * 100);
if (!native) {
root.style.setProperty('--sp-04-p', p.toFixed(4));
root.style.setProperty('--sp-04-n', String(n));
if (!CSS.supports('background', 'paint(x)') && !CSS.registerProperty) tip.textContent = n + '%';
}
if (n !== last) { last = n; sr.setAttribute('aria-valuenow', String(n)); sr.textContent = n + '% read'; }
};
const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } };
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
paint();
})();(() => {
const root = document.querySelector('.sp-04');
if (!root || root.dataset.spWired) return;
root.dataset.spWired = '1';
const sr = root.querySelector('#sp-04-sr');
const tip = root.querySelector('#sp-04-tip');
const native = CSS.supports('animation-timeline', 'scroll()');
let ticking = false, last = -1;
const paint = () => {
ticking = false;
const d = document.documentElement;
const max = d.scrollHeight - d.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, d.scrollTop / max)) : 0;
const n = Math.round(p * 100);
if (!native) {
root.style.setProperty('--sp-04-p', p.toFixed(4));
root.style.setProperty('--sp-04-n', String(n));
if (!CSS.supports('background', 'paint(x)') && !CSS.registerProperty) tip.textContent = n + '%';
}
if (n !== last) { last = n; sr.setAttribute('aria-valuenow', String(n)); sr.textContent = n + '% read'; }
};
const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } };
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
paint();
})();Here's a working CSS Scroll Progress Bar from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Scroll Progress Bar with Percentage Counter Tooltip
Source: https://codefronts.com/motion/css-scroll-progress-bar/scroll-progress-bar-with-percentage-counter-tooltip/
A progress bar with a live numeric readout that rides along the fill — and the number itself is generated by CSS, not JavaScript. Registering an <integer> custom property with @property makes it animatable, and counter() turns that animated integer into rendered text. The result is a scroll percentage with zero string concatenation on any frame.
## HTML
```html
<section class="sp-04" aria-label="Scroll progress bar with percentage tooltip demo">
<div class="sp-04__rail" aria-hidden="true">
<div class="sp-04__track"><i class="sp-04__fill"></i></div>
<div class="sp-04__tipwrap"><span class="sp-04__tip" id="sp-04-tip"></span></div>
</div>
<p class="sp-04__sr" role="progressbar" aria-label="Page read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="sp-04-sr">0% read</p>
<header class="sp-04__bar">
<a class="sp-04__brand" href="#sp-04-s1"><span class="sp-04__logo" aria-hidden="true"></span>Counter</a>
<p class="sp-04__meta">@property · counter()</p>
</header>
<div class="sp-04__intro">
<div class="sp-04__introin">
<p class="sp-04__intro-eyebrow">counter-reset: sp var(--n)</p>
<h2 class="sp-04__intro-title">CSS can count. Let it.</h2>
<p class="sp-04__intro-sub">The bubble riding the bar is generated content — an animated registered integer printed through a CSS counter. No template string is built on any frame.</p>
</div>
</div>
<main class="sp-04__main">
<section class="sp-04__sec" id="sp-04-s1">
<p class="sp-04__kicker">Registration</p>
<h3 class="sp-04__h">Why @property is the whole trick</h3>
<p class="sp-04__p sp-04__p--lede">An unregistered custom property is an opaque token stream. The engine has no idea 0 and 100 are numbers, so it cannot interpolate — it just swaps at the halfway point.</p>
<p class="sp-04__p">Register it with <code>syntax:'<integer>'</code> and the same declaration becomes a first-class animatable value.</p>
</section>
<section class="sp-04__sec" id="sp-04-s2">
<p class="sp-04__kicker">Rendering</p>
<h3 class="sp-04__h">counter() is the only way out</h3>
<p class="sp-04__p">There is no <code>content: var(--n)</code> that prints a number. Feeding the value into <code>counter-reset</code> and printing it with <code>counter()</code> is the sanctioned route, and it has worked since CSS 2.1.</p>
</section>
<section class="sp-04__sec" id="sp-04-s3">
<p class="sp-04__kicker">Tracking</p>
<h3 class="sp-04__h">One timeline, two animations</h3>
<p class="sp-04__p">The fill and the bubble share <code>scroll(root block)</code>. Two JavaScript updates throttled independently will eventually disagree by a frame; two animations on one timeline cannot.</p>
<figure class="sp-04__fig">
<img class="sp-04__img" src="https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=1400&q=70" alt="A laptop showing code on a desk beside a plant" loading="lazy" decoding="async">
<figcaption class="sp-04__cap">Shared timelines are the scroll-driven equivalent of a single source of truth.</figcaption>
</figure>
</section>
<section class="sp-04__sec" id="sp-04-s4">
<p class="sp-04__kicker">Accessibility</p>
<h3 class="sp-04__h">Generated content is not an announcement</h3>
<p class="sp-04__p">Text drawn by <code>::after</code> is inconsistently exposed and never selectable. A real <code>progressbar</code> element with a throttled <code>aria-valuenow</code> carries the meaning.</p>
<p class="sp-04__chip">0 string builds per frame</p>
</section>
</main>
<footer class="sp-04__pagefoot"><p class="sp-04__pagefootin">Scroll progress pattern 04 of 18 · codefronts.com</p></footer>
</section>
```
## CSS
```css
@property --sp-04-n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --sp-04-p {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.sp-04 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--sp-04-bg);
--sp-04-bg: oklch(0.15 0.02 285);
--sp-04-surface: oklch(0.2 0.025 285);
--sp-04-ink: oklch(0.97 0.005 285);
--sp-04-mut: oklch(0.72 0.02 285);
--sp-04-acc: oklch(0.78 0.17 85);
--sp-04-acc2: oklch(0.7 0.19 30);
--sp-04-line: oklch(1 0 0/.12);
--sp-04-h: 6px;
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--sp-04-ink);
-webkit-font-smoothing: antialiased;
}
.sp-04 *,
.sp-04 *::before,
.sp-04 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.sp-04__rail {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
z-index: 60;
pointer-events: none;
}
.sp-04__track {
block-size: var(--sp-04-h);
background: oklch(1 0 0/.09);
}
.sp-04__fill {
display: block;
block-size: 100%;
transform-origin: 0 50%;
transform: scaleX(var(--sp-04-p));
background: linear-gradient(90deg,var(--sp-04-acc2),var(--sp-04-acc));
box-shadow: 0 0 16px -2px var(--sp-04-acc);
}
.sp-04__tipwrap {
position: relative;
block-size: 0;
margin-inline: 32px;
}
.sp-04__tip {
position: absolute;
inset-block-start: 6px;
inset-inline-start: 0;
translate: calc(var(--sp-04-p) * 100 * 1%) 0;
transform: translateX(-50%);
counter-reset: sp04 var(--sp-04-n);
display: block;
padding: 4px 9px;
border-radius: 8px;
background: var(--sp-04-acc);
color: oklch(0.18 0.03 85);
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
box-shadow: 0 6px 18px -8px oklch(0 0 0/.8);
}
.sp-04__tip::after {
content: counter(sp04) "%";
}
.sp-04__tip::before {
content: "";
position: absolute;
inset-block-start: -3px;
inset-inline-start: 50%;
inline-size: 7px;
block-size: 7px;
background: inherit;
transform: translateX(-50%) rotate(45deg);
border-radius: 1px;
}
@supports (animation-timeline: scroll()) {
.sp-04__fill {
transform: scaleX(0);
animation: sp-04-fill linear both;
animation-timeline: scroll(root block);
}
.sp-04__tip {
animation: sp-04-count linear both;
animation-timeline: scroll(root block);
}
}
@keyframes sp-04-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes sp-04-count {
from {
--sp-04-n: 0;
translate: 0 0;
}
to {
--sp-04-n: 100;
translate: 100% 0;
}
}
.sp-04__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.sp-04__bar {
position: sticky;
inset-block-start: 0;
z-index: 40;
display: flex;
align-items: flex-end;
gap: 14px;
min-block-size: 80px;
padding-block: calc(var(--sp-04-h) + 26px) 12px;
background: color-mix(in oklch,var(--sp-04-bg) 72%,transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-block-end: 1px solid var(--sp-04-line);
}
.sp-04__brand {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 730;
letter-spacing: -.02em;
color: inherit;
text-decoration: none;
}
.sp-04__brand:focus-visible {
outline: 2px solid var(--sp-04-acc);
outline-offset: 3px;
border-radius: 6px;
}
.sp-04__logo {
inline-size: 18px;
block-size: 18px;
border-radius: 5px;
background: linear-gradient(140deg,var(--sp-04-acc),var(--sp-04-acc2));
}
.sp-04__meta {
margin-inline-start: auto;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--sp-04-mut);
}
.sp-04__intro {
display: grid;
place-items: center;
min-block-size: min(86svh,820px);
padding-block: clamp(56px,12vh,140px) clamp(40px,8vh,90px);
background: radial-gradient(90% 60% at 50% 0%,oklch(0.28 0.07 60),transparent);
}
.sp-04__introin {
display: grid;
justify-items: center;
gap: clamp(12px,1.8vw,18px);
text-align: center;
max-inline-size: 26ch;
}
.sp-04__intro-eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: clamp(11px,1.1vw,13px);
letter-spacing: .12em;
text-transform: uppercase;
color: var(--sp-04-acc);
}
.sp-04__intro-title {
font-size: clamp(38px,7.4vw,84px);
line-height: .98;
letter-spacing: -.04em;
font-weight: 730;
text-wrap: balance;
}
.sp-04__intro-sub {
font-size: clamp(15px,1.7vw,19px);
line-height: 1.55;
color: var(--sp-04-mut);
max-inline-size: 48ch;
text-wrap: pretty;
}
.sp-04__sec {
scroll-margin-top: 96px;
display: grid;
gap: 12px;
align-content: center;
min-block-size: 72svh;
padding-block: clamp(28px,5vw,56px);
border-block-end: 1px solid var(--sp-04-line);
}
.sp-04__sec:last-child {
border-block-end: 0;
}
.sp-04__kicker {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--sp-04-acc);
}
.sp-04__h {
font-size: clamp(22px,3.2vw,34px);
line-height: 1.1;
letter-spacing: -.032em;
font-weight: 700;
text-wrap: balance;
}
.sp-04__p {
max-inline-size: 60ch;
font-size: 15.8px;
line-height: 1.7;
color: var(--sp-04-mut);
text-wrap: pretty;
}
.sp-04__p--lede {
font-size: 18px;
color: var(--sp-04-ink);
}
.sp-04__p code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .85em;
background: oklch(1 0 0/.1);
padding: 2px 6px;
border-radius: 5px;
}
.sp-04__fig {
display: grid;
gap: 9px;
max-inline-size: 70ch;
}
.sp-04__img {
inline-size: 100%;
aspect-ratio: 16/7;
object-fit: cover;
display: block;
border-radius: 16px;
background: linear-gradient(135deg,oklch(0.4 0.09 60),oklch(0.25 0.05 300));
}
.sp-04__cap {
font-size: 12.6px;
color: var(--sp-04-mut);
}
.sp-04__chip {
justify-self: start;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--sp-04-acc);
padding: 7px 13px;
border-radius: 99px;
background: color-mix(in oklch,var(--sp-04-acc) 14%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-04-acc) 30%,transparent);
}
.sp-04__pagefoot {
display: grid;
place-items: center;
padding: clamp(44px,9vh,110px) 24px;
border-block-start: 1px solid var(--sp-04-line);
}
.sp-04__pagefootin {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
letter-spacing: .06em;
color: var(--sp-04-mut);
text-align: center;
}
.sp-04__bar {
padding-inline: max(clamp(16px,3vw,26px),calc((100% - 1180px)/2));
}
.sp-04__intro,
.sp-04__sec {
padding-inline: max(clamp(22px,5vw,44px),calc((100% - 900px)/2));
}
:root:has(.sp-04) {
scroll-behavior: smooth;
scroll-padding-top: 96px;
}
@media (max-width: 640px) {
.sp-04__sec {
min-block-size: auto;
}
}
@media (prefers-reduced-motion: reduce) {
:root:has(.sp-04) {
scroll-behavior: auto;
}
.sp-04__fill {
box-shadow: none;
}
.sp-04 * {
transition-duration: .01ms !important;
}
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.sp-04');
if (!root || root.dataset.spWired) return;
root.dataset.spWired = '1';
const sr = root.querySelector('#sp-04-sr');
const tip = root.querySelector('#sp-04-tip');
const native = CSS.supports('animation-timeline', 'scroll()');
let ticking = false, last = -1;
const paint = () => {
ticking = false;
const d = document.documentElement;
const max = d.scrollHeight - d.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, d.scrollTop / max)) : 0;
const n = Math.round(p * 100);
if (!native) {
root.style.setProperty('--sp-04-p', p.toFixed(4));
root.style.setProperty('--sp-04-n', String(n));
if (!CSS.supports('background', 'paint(x)') && !CSS.registerProperty) tip.textContent = n + '%';
}
if (n !== last) { last = n; sr.setAttribute('aria-valuenow', String(n)); sr.textContent = n + '% read'; }
};
const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } };
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
paint();
})();
```Here's a working CSS Scroll Progress Bar from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Scroll Progress Bar with Percentage Counter Tooltip
Source: https://codefronts.com/motion/css-scroll-progress-bar/scroll-progress-bar-with-percentage-counter-tooltip/
A progress bar with a live numeric readout that rides along the fill — and the number itself is generated by CSS, not JavaScript. Registering an <integer> custom property with @property makes it animatable, and counter() turns that animated integer into rendered text. The result is a scroll percentage with zero string concatenation on any frame.
## HTML
```html
<section class="sp-04" aria-label="Scroll progress bar with percentage tooltip demo">
<div class="sp-04__rail" aria-hidden="true">
<div class="sp-04__track"><i class="sp-04__fill"></i></div>
<div class="sp-04__tipwrap"><span class="sp-04__tip" id="sp-04-tip"></span></div>
</div>
<p class="sp-04__sr" role="progressbar" aria-label="Page read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="sp-04-sr">0% read</p>
<header class="sp-04__bar">
<a class="sp-04__brand" href="#sp-04-s1"><span class="sp-04__logo" aria-hidden="true"></span>Counter</a>
<p class="sp-04__meta">@property · counter()</p>
</header>
<div class="sp-04__intro">
<div class="sp-04__introin">
<p class="sp-04__intro-eyebrow">counter-reset: sp var(--n)</p>
<h2 class="sp-04__intro-title">CSS can count. Let it.</h2>
<p class="sp-04__intro-sub">The bubble riding the bar is generated content — an animated registered integer printed through a CSS counter. No template string is built on any frame.</p>
</div>
</div>
<main class="sp-04__main">
<section class="sp-04__sec" id="sp-04-s1">
<p class="sp-04__kicker">Registration</p>
<h3 class="sp-04__h">Why @property is the whole trick</h3>
<p class="sp-04__p sp-04__p--lede">An unregistered custom property is an opaque token stream. The engine has no idea 0 and 100 are numbers, so it cannot interpolate — it just swaps at the halfway point.</p>
<p class="sp-04__p">Register it with <code>syntax:'<integer>'</code> and the same declaration becomes a first-class animatable value.</p>
</section>
<section class="sp-04__sec" id="sp-04-s2">
<p class="sp-04__kicker">Rendering</p>
<h3 class="sp-04__h">counter() is the only way out</h3>
<p class="sp-04__p">There is no <code>content: var(--n)</code> that prints a number. Feeding the value into <code>counter-reset</code> and printing it with <code>counter()</code> is the sanctioned route, and it has worked since CSS 2.1.</p>
</section>
<section class="sp-04__sec" id="sp-04-s3">
<p class="sp-04__kicker">Tracking</p>
<h3 class="sp-04__h">One timeline, two animations</h3>
<p class="sp-04__p">The fill and the bubble share <code>scroll(root block)</code>. Two JavaScript updates throttled independently will eventually disagree by a frame; two animations on one timeline cannot.</p>
<figure class="sp-04__fig">
<img class="sp-04__img" src="https://images.unsplash.com/photo-1517336714731-489689fd1ca8?auto=format&fit=crop&w=1400&q=70" alt="A laptop showing code on a desk beside a plant" loading="lazy" decoding="async">
<figcaption class="sp-04__cap">Shared timelines are the scroll-driven equivalent of a single source of truth.</figcaption>
</figure>
</section>
<section class="sp-04__sec" id="sp-04-s4">
<p class="sp-04__kicker">Accessibility</p>
<h3 class="sp-04__h">Generated content is not an announcement</h3>
<p class="sp-04__p">Text drawn by <code>::after</code> is inconsistently exposed and never selectable. A real <code>progressbar</code> element with a throttled <code>aria-valuenow</code> carries the meaning.</p>
<p class="sp-04__chip">0 string builds per frame</p>
</section>
</main>
<footer class="sp-04__pagefoot"><p class="sp-04__pagefootin">Scroll progress pattern 04 of 18 · codefronts.com</p></footer>
</section>
```
## CSS
```css
@property --sp-04-n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@property --sp-04-p {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.sp-04 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--sp-04-bg);
--sp-04-bg: oklch(0.15 0.02 285);
--sp-04-surface: oklch(0.2 0.025 285);
--sp-04-ink: oklch(0.97 0.005 285);
--sp-04-mut: oklch(0.72 0.02 285);
--sp-04-acc: oklch(0.78 0.17 85);
--sp-04-acc2: oklch(0.7 0.19 30);
--sp-04-line: oklch(1 0 0/.12);
--sp-04-h: 6px;
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--sp-04-ink);
-webkit-font-smoothing: antialiased;
}
.sp-04 *,
.sp-04 *::before,
.sp-04 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.sp-04__rail {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
z-index: 60;
pointer-events: none;
}
.sp-04__track {
block-size: var(--sp-04-h);
background: oklch(1 0 0/.09);
}
.sp-04__fill {
display: block;
block-size: 100%;
transform-origin: 0 50%;
transform: scaleX(var(--sp-04-p));
background: linear-gradient(90deg,var(--sp-04-acc2),var(--sp-04-acc));
box-shadow: 0 0 16px -2px var(--sp-04-acc);
}
.sp-04__tipwrap {
position: relative;
block-size: 0;
margin-inline: 32px;
}
.sp-04__tip {
position: absolute;
inset-block-start: 6px;
inset-inline-start: 0;
translate: calc(var(--sp-04-p) * 100 * 1%) 0;
transform: translateX(-50%);
counter-reset: sp04 var(--sp-04-n);
display: block;
padding: 4px 9px;
border-radius: 8px;
background: var(--sp-04-acc);
color: oklch(0.18 0.03 85);
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
box-shadow: 0 6px 18px -8px oklch(0 0 0/.8);
}
.sp-04__tip::after {
content: counter(sp04) "%";
}
.sp-04__tip::before {
content: "";
position: absolute;
inset-block-start: -3px;
inset-inline-start: 50%;
inline-size: 7px;
block-size: 7px;
background: inherit;
transform: translateX(-50%) rotate(45deg);
border-radius: 1px;
}
@supports (animation-timeline: scroll()) {
.sp-04__fill {
transform: scaleX(0);
animation: sp-04-fill linear both;
animation-timeline: scroll(root block);
}
.sp-04__tip {
animation: sp-04-count linear both;
animation-timeline: scroll(root block);
}
}
@keyframes sp-04-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes sp-04-count {
from {
--sp-04-n: 0;
translate: 0 0;
}
to {
--sp-04-n: 100;
translate: 100% 0;
}
}
.sp-04__sr {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.sp-04__bar {
position: sticky;
inset-block-start: 0;
z-index: 40;
display: flex;
align-items: flex-end;
gap: 14px;
min-block-size: 80px;
padding-block: calc(var(--sp-04-h) + 26px) 12px;
background: color-mix(in oklch,var(--sp-04-bg) 72%,transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-block-end: 1px solid var(--sp-04-line);
}
.sp-04__brand {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 730;
letter-spacing: -.02em;
color: inherit;
text-decoration: none;
}
.sp-04__brand:focus-visible {
outline: 2px solid var(--sp-04-acc);
outline-offset: 3px;
border-radius: 6px;
}
.sp-04__logo {
inline-size: 18px;
block-size: 18px;
border-radius: 5px;
background: linear-gradient(140deg,var(--sp-04-acc),var(--sp-04-acc2));
}
.sp-04__meta {
margin-inline-start: auto;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--sp-04-mut);
}
.sp-04__intro {
display: grid;
place-items: center;
min-block-size: min(86svh,820px);
padding-block: clamp(56px,12vh,140px) clamp(40px,8vh,90px);
background: radial-gradient(90% 60% at 50% 0%,oklch(0.28 0.07 60),transparent);
}
.sp-04__introin {
display: grid;
justify-items: center;
gap: clamp(12px,1.8vw,18px);
text-align: center;
max-inline-size: 26ch;
}
.sp-04__intro-eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: clamp(11px,1.1vw,13px);
letter-spacing: .12em;
text-transform: uppercase;
color: var(--sp-04-acc);
}
.sp-04__intro-title {
font-size: clamp(38px,7.4vw,84px);
line-height: .98;
letter-spacing: -.04em;
font-weight: 730;
text-wrap: balance;
}
.sp-04__intro-sub {
font-size: clamp(15px,1.7vw,19px);
line-height: 1.55;
color: var(--sp-04-mut);
max-inline-size: 48ch;
text-wrap: pretty;
}
.sp-04__sec {
scroll-margin-top: 96px;
display: grid;
gap: 12px;
align-content: center;
min-block-size: 72svh;
padding-block: clamp(28px,5vw,56px);
border-block-end: 1px solid var(--sp-04-line);
}
.sp-04__sec:last-child {
border-block-end: 0;
}
.sp-04__kicker {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--sp-04-acc);
}
.sp-04__h {
font-size: clamp(22px,3.2vw,34px);
line-height: 1.1;
letter-spacing: -.032em;
font-weight: 700;
text-wrap: balance;
}
.sp-04__p {
max-inline-size: 60ch;
font-size: 15.8px;
line-height: 1.7;
color: var(--sp-04-mut);
text-wrap: pretty;
}
.sp-04__p--lede {
font-size: 18px;
color: var(--sp-04-ink);
}
.sp-04__p code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .85em;
background: oklch(1 0 0/.1);
padding: 2px 6px;
border-radius: 5px;
}
.sp-04__fig {
display: grid;
gap: 9px;
max-inline-size: 70ch;
}
.sp-04__img {
inline-size: 100%;
aspect-ratio: 16/7;
object-fit: cover;
display: block;
border-radius: 16px;
background: linear-gradient(135deg,oklch(0.4 0.09 60),oklch(0.25 0.05 300));
}
.sp-04__cap {
font-size: 12.6px;
color: var(--sp-04-mut);
}
.sp-04__chip {
justify-self: start;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--sp-04-acc);
padding: 7px 13px;
border-radius: 99px;
background: color-mix(in oklch,var(--sp-04-acc) 14%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-04-acc) 30%,transparent);
}
.sp-04__pagefoot {
display: grid;
place-items: center;
padding: clamp(44px,9vh,110px) 24px;
border-block-start: 1px solid var(--sp-04-line);
}
.sp-04__pagefootin {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
letter-spacing: .06em;
color: var(--sp-04-mut);
text-align: center;
}
.sp-04__bar {
padding-inline: max(clamp(16px,3vw,26px),calc((100% - 1180px)/2));
}
.sp-04__intro,
.sp-04__sec {
padding-inline: max(clamp(22px,5vw,44px),calc((100% - 900px)/2));
}
:root:has(.sp-04) {
scroll-behavior: smooth;
scroll-padding-top: 96px;
}
@media (max-width: 640px) {
.sp-04__sec {
min-block-size: auto;
}
}
@media (prefers-reduced-motion: reduce) {
:root:has(.sp-04) {
scroll-behavior: auto;
}
.sp-04__fill {
box-shadow: none;
}
.sp-04 * {
transition-duration: .01ms !important;
}
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.sp-04');
if (!root || root.dataset.spWired) return;
root.dataset.spWired = '1';
const sr = root.querySelector('#sp-04-sr');
const tip = root.querySelector('#sp-04-tip');
const native = CSS.supports('animation-timeline', 'scroll()');
let ticking = false, last = -1;
const paint = () => {
ticking = false;
const d = document.documentElement;
const max = d.scrollHeight - d.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, d.scrollTop / max)) : 0;
const n = Math.round(p * 100);
if (!native) {
root.style.setProperty('--sp-04-p', p.toFixed(4));
root.style.setProperty('--sp-04-n', String(n));
if (!CSS.supports('background', 'paint(x)') && !CSS.registerProperty) tip.textContent = n + '%';
}
if (n !== last) { last = n; sr.setAttribute('aria-valuenow', String(n)); sr.textContent = n + '% read'; }
};
const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } };
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll, { passive: true });
paint();
})();
```How this works
CSS cannot print the value of a custom property as text — content: var(--n) renders nothing useful. Counters can:
@property --sp-n{ syntax:'<integer>'; inherits:true; initial-value:0 }
@keyframes sp-count{ from{ --sp-n:0 } to{ --sp-n:100 } }
.tip{ counter-reset: sp var(--sp-n); animation: sp-count linear both;
animation-timeline: scroll(root block) }
.tip::after{ content: counter(sp) '%' }Two things have to be true for this to work. First, the property must be registered — an unregistered custom property is just a token stream and cannot interpolate, so the animation would snap from 0 to 100 with nothing in between. Second, the syntax must be <integer>, because counter-reset only accepts whole numbers; a <number> would produce fractional values that the counter silently rejects.
The tooltip tracks the fill by animating translate along a full-width rail, with translate:-50% baked into the transform so the bubble stays centred on the leading edge. Both animations — the fill, and the counter-plus-position — share the same scroll(root block) timeline, so they can never drift apart the way two independently-throttled JS updates can.
For assistive technology the visual counter is not enough: generated content from ::after is inconsistently exposed. So a real role="progressbar" element carries aria-valuenow, updated by the fallback script at a low frequency — a value announced on every pixel of scroll is hostile, once a second is useful.
Make it yours
- Change the range:
@keyframesending at--sp-04-n: 100can end anywhere. Counting down (100→0, 'left to read') is a one-token edit and tests measurably better on long-form pages. - Suffix and prefix live in
content:content: counter(sp) '% read', or drop the unit entirely for a minimal chip. - Pin the tooltip instead of tracking: remove the
translatekeyframes and give itinset-inline-end:16pxfor a fixed corner readout — cheaper and calmer on mobile. - Words instead of numbers: swap
counter()for a@container style(--sp-04-n > 90)rule that prints 'nearly done' — style queries make named thresholds possible with no script. - Change the bubble tail by rotating a 8px square pseudo-element 45deg; keep it as a child so the counter content stays on the parent.
Gotchas — read before shipping
- An unregistered custom property cannot be animated — no error, the value just jumps at the 50% mark.
@propertyregistration is mandatory, not optional polish. syntax:'<number>'breakscounter-reset, which requires an integer. Use<integer>and let the engine round.counter()output lives in generated content — it cannot be selected, copied, translated by browser translation tools, or reliably read by every screen reader. Always pair it with a real ARIA value.- A tooltip that reaches 100% at the very edge of the viewport gets clipped. Constrain the rail with
inset-inline: 8px(or clamp the translate) so the bubble stays fully visible at both ends. - Registered properties inherit by default only if you say so —
inherits:truematters here because the counter is read on a descendant. - Updating
aria-valuenowon every scroll frame floods the accessibility tree. Throttle announcements to whole-percent changes at most.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 115+ | 26+ | 144+ | 115+ |
@property is Chrome 85+, Safari 16.4+, Firefox 128+ — broader than scroll timelines, so the counter technique itself is safe. Where animation-timeline is missing the fallback script writes --sp-04-n directly and the counter still renders; where @property is missing too, the bar and the JS-written text still work.