18 CSS Scroll Progress Bar15 / 18
Custom ::-webkit-scrollbar with Progress Indicator
Two scrollbar systems exist and neither is going away: WebKit's shadow pseudo-elements and the standard scrollbar-width/scrollbar-color pair. This demo ships both correctly — then shows why the popular 'fill the scrollbar track with a gradient' trick cannot work (backgrounds stop at the padding box) and replaces it with a six-pixel gauge that reads the panel's own scroll timeline.
Published
The code
<section class="sp-15" aria-label="Custom scrollbar with progress indicator demo">
<header class="sp-15__bar">
<a class="sp-15__brand" href="#sp-15-box"><span class="sp-15__logo" aria-hidden="true"></span>Gutter</a>
<p class="sp-15__meta">::-webkit-scrollbar + scrollbar-color</p>
</header>
<div class="sp-15__intro">
<p class="sp-15__intro-eyebrow">two systems, both required</p>
<h2 class="sp-15__intro-title">A scrollbar that doubles as a progress bar</h2>
<p class="sp-15__intro-sub">Scroll inside the panel. The scrollbar is styled in both systems, and the six-pixel gauge beside it reads the panel's own scroll timeline.</p>
</div>
<div class="sp-15__stage">
<div class="sp-15__boxwrap">
<div class="sp-15__box" id="sp-15-box" tabindex="0" role="region" aria-label="Release notes, scrollable">
<p class="sp-15__lede">Release notes · v4.2</p>
<h4 class="sp-15__sh">Standard first</h4>
<p class="sp-15__bp"><code>scrollbar-width</code> and <code>scrollbar-color</code> are the interoperable properties. They are coarse — three widths and two colours — but they are honoured by Firefox and increasingly by everyone else.</p>
<h4 class="sp-15__sh">WebKit for the detail</h4>
<p class="sp-15__bp">Radii, gradients, inset padding and hover growth all live in the pseudo-elements. Layer them on top rather than instead.</p>
<h4 class="sp-15__sh">The inset thumb trick</h4>
<p class="sp-15__bp">A transparent border plus <code>background-clip: content-box</code> is how the thumb appears to float inside the track. The border reserves space the background never paints into.</p>
<h4 class="sp-15__sh">The progress layer</h4>
<p class="sp-15__bp">You cannot paint it inside the scrollbar. A background's positioning area stops at the padding box, so a gradient at <code>right top</code> lands under the text padding — and overlay or 2px scrollbars leave nothing to see through. The gauge to the right is a real element on this panel's named timeline.</p>
<h4 class="sp-15__sh">Gutter stability</h4>
<p class="sp-15__bp"><code>scrollbar-gutter: stable</code> reserves the space up front so text does not reflow the moment content grows past one screen.</p>
<h4 class="sp-15__sh">Never hide it</h4>
<p class="sp-15__bp">A hidden scrollbar removes the only cue that content continues and takes away a drag target some users depend on. Hide it only where another indicator exists.</p>
<h4 class="sp-15__sh">Grab size</h4>
<p class="sp-15__bp">Below roughly 8 by 24 pixels a thumb stops being grabbable. Thin is a style; invisible is a bug.</p>
<h4 class="sp-15__sh">Platform truth</h4>
<p class="sp-15__bp">iOS draws overlay scrollbars at the system level and ignores your styling entirely. Treat scrollbar design as decoration, never as the only signal.</p>
<p class="sp-15__end">End of notes — the gauge beside the scrollbar is now full.</p>
</div>
<div class="sp-15__gauge" role="progressbar" aria-label="Release notes read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="sp-15-gauge"><i class="sp-15__gfill"></i></div>
</div>
<div class="sp-15__legend">
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--thumb"></span><span>Thumb · <code>scrollbar-color</code> arg 1</span></p>
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--track"></span><span>Gauge · a real element, bound to the panel's named scroll timeline</span></p>
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--gut"></span><span>Gutter · reserved by <code>scrollbar-gutter</code></span></p>
</div>
</div>
<section class="sp-15__notes">
<p class="sp-15__kicker">Coverage</p>
<h3 class="sp-15__h">Write both, always — and don't trust either for meaning</h3>
<p class="sp-15__p">WebKit pseudo-elements will never land in Firefox, and the standard properties cannot express a gradient thumb. Shipping only one leaves a large slice of users with a bar that does not match the design — and iOS ignores both, drawing its own overlay bar. Anything that has to <em>communicate</em> position belongs in an element you control.</p>
<p class="sp-15__chip">2 systems · 1 gauge · 0 hidden scrollbars</p>
</section>
<footer class="sp-15__pagefoot"><p class="sp-15__pagefootin">Scroll progress pattern 15 of 18 · codefronts.com</p></footer>
</section><section class="sp-15" aria-label="Custom scrollbar with progress indicator demo">
<header class="sp-15__bar">
<a class="sp-15__brand" href="#sp-15-box"><span class="sp-15__logo" aria-hidden="true"></span>Gutter</a>
<p class="sp-15__meta">::-webkit-scrollbar + scrollbar-color</p>
</header>
<div class="sp-15__intro">
<p class="sp-15__intro-eyebrow">two systems, both required</p>
<h2 class="sp-15__intro-title">A scrollbar that doubles as a progress bar</h2>
<p class="sp-15__intro-sub">Scroll inside the panel. The scrollbar is styled in both systems, and the six-pixel gauge beside it reads the panel's own scroll timeline.</p>
</div>
<div class="sp-15__stage">
<div class="sp-15__boxwrap">
<div class="sp-15__box" id="sp-15-box" tabindex="0" role="region" aria-label="Release notes, scrollable">
<p class="sp-15__lede">Release notes · v4.2</p>
<h4 class="sp-15__sh">Standard first</h4>
<p class="sp-15__bp"><code>scrollbar-width</code> and <code>scrollbar-color</code> are the interoperable properties. They are coarse — three widths and two colours — but they are honoured by Firefox and increasingly by everyone else.</p>
<h4 class="sp-15__sh">WebKit for the detail</h4>
<p class="sp-15__bp">Radii, gradients, inset padding and hover growth all live in the pseudo-elements. Layer them on top rather than instead.</p>
<h4 class="sp-15__sh">The inset thumb trick</h4>
<p class="sp-15__bp">A transparent border plus <code>background-clip: content-box</code> is how the thumb appears to float inside the track. The border reserves space the background never paints into.</p>
<h4 class="sp-15__sh">The progress layer</h4>
<p class="sp-15__bp">You cannot paint it inside the scrollbar. A background's positioning area stops at the padding box, so a gradient at <code>right top</code> lands under the text padding — and overlay or 2px scrollbars leave nothing to see through. The gauge to the right is a real element on this panel's named timeline.</p>
<h4 class="sp-15__sh">Gutter stability</h4>
<p class="sp-15__bp"><code>scrollbar-gutter: stable</code> reserves the space up front so text does not reflow the moment content grows past one screen.</p>
<h4 class="sp-15__sh">Never hide it</h4>
<p class="sp-15__bp">A hidden scrollbar removes the only cue that content continues and takes away a drag target some users depend on. Hide it only where another indicator exists.</p>
<h4 class="sp-15__sh">Grab size</h4>
<p class="sp-15__bp">Below roughly 8 by 24 pixels a thumb stops being grabbable. Thin is a style; invisible is a bug.</p>
<h4 class="sp-15__sh">Platform truth</h4>
<p class="sp-15__bp">iOS draws overlay scrollbars at the system level and ignores your styling entirely. Treat scrollbar design as decoration, never as the only signal.</p>
<p class="sp-15__end">End of notes — the gauge beside the scrollbar is now full.</p>
</div>
<div class="sp-15__gauge" role="progressbar" aria-label="Release notes read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="sp-15-gauge"><i class="sp-15__gfill"></i></div>
</div>
<div class="sp-15__legend">
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--thumb"></span><span>Thumb · <code>scrollbar-color</code> arg 1</span></p>
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--track"></span><span>Gauge · a real element, bound to the panel's named scroll timeline</span></p>
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--gut"></span><span>Gutter · reserved by <code>scrollbar-gutter</code></span></p>
</div>
</div>
<section class="sp-15__notes">
<p class="sp-15__kicker">Coverage</p>
<h3 class="sp-15__h">Write both, always — and don't trust either for meaning</h3>
<p class="sp-15__p">WebKit pseudo-elements will never land in Firefox, and the standard properties cannot express a gradient thumb. Shipping only one leaves a large slice of users with a bar that does not match the design — and iOS ignores both, drawing its own overlay bar. Anything that has to <em>communicate</em> position belongs in an element you control.</p>
<p class="sp-15__chip">2 systems · 1 gauge · 0 hidden scrollbars</p>
</section>
<footer class="sp-15__pagefoot"><p class="sp-15__pagefootin">Scroll progress pattern 15 of 18 · codefronts.com</p></footer>
</section>.sp-15 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--sp-15-bg);
--sp-15-bg: oklch(0.16 0.014 275);
--sp-15-surface: oklch(0.21 0.018 275);
--sp-15-ink: oklch(0.97 0.004 275);
--sp-15-mut: oklch(0.72 0.016 275);
--sp-15-acc: oklch(0.74 0.15 100);
--sp-15-track: oklch(1 0 0/.08);
--sp-15-line: oklch(1 0 0/.12);
--sp-15-p: 0;
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--sp-15-ink);
-webkit-font-smoothing: antialiased;
}
.sp-15 *,
.sp-15 *::before,
.sp-15 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.sp-15__bar {
position: sticky;
inset-block-start: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 14px;
min-block-size: 58px;
background: color-mix(in oklch,var(--sp-15-bg) 78%,transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-block-end: 1px solid var(--sp-15-line);
}
.sp-15__brand {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 730;
letter-spacing: -.02em;
color: inherit;
text-decoration: none;
}
.sp-15__brand:focus-visible {
outline: 2px solid var(--sp-15-acc);
outline-offset: 3px;
border-radius: 6px;
}
.sp-15__logo {
inline-size: 7px;
block-size: 20px;
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.6 0.16 140));
}
.sp-15__meta {
margin-inline-start: auto;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .06em;
color: var(--sp-15-mut);
}
.sp-15__intro {
display: grid;
gap: 14px;
padding-block: clamp(44px,10vh,110px) clamp(22px,5vh,46px);
}
.sp-15__intro-eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__intro-title {
font-size: clamp(30px,5.4vw,62px);
line-height: 1;
letter-spacing: -.04em;
font-weight: 730;
max-inline-size: 18ch;
text-wrap: balance;
}
.sp-15__intro-sub {
font-size: clamp(14.5px,1.6vw,18px);
line-height: 1.55;
color: var(--sp-15-mut);
max-inline-size: 54ch;
text-wrap: pretty;
}
.sp-15__stage {
display: grid;
grid-template-columns: minmax(0,1fr) minmax(200px,250px);
gap: clamp(16px,3vw,32px);
align-items: start;
padding-block-end: clamp(30px,6vh,64px);
}
.sp-15__boxwrap {
position: relative;
timeline-scope: --sp-15-tl;
padding-inline-end: 18px;
}
.sp-15__gauge {
position: absolute;
inset-block: 8px;
inset-inline-end: 0;
inline-size: 6px;
border-radius: 99px;
background: var(--sp-15-track);
box-shadow: inset 0 0 0 1px var(--sp-15-line);
overflow: hidden;
pointer-events: none;
}
.sp-15__gfill {
display: block;
inline-size: 100%;
block-size: 100%;
transform-origin: 50% 0;
transform: scaleY(var(--sp-15-p));
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
}
.sp-15__box {
block-size: min(62svh,520px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--sp-15-acc) var(--sp-15-track);
padding: 20px clamp(16px,3vw,26px);
border: 1px solid var(--sp-15-line);
border-radius: 16px;
background: var(--sp-15-surface);
display: grid;
gap: 11px;
align-content: start;
}
@supports (animation-timeline: scroll()) {
.sp-15__box {
scroll-timeline-name: --sp-15-tl;
scroll-timeline-axis: block;
}
.sp-15__gfill {
animation: sp-15-track linear;
animation-timeline: --sp-15-tl;
}
}
@keyframes sp-15-track {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.sp-15__box::-webkit-scrollbar {
inline-size: 12px;
}
.sp-15__box::-webkit-scrollbar-track {
background: transparent;
}
.sp-15__box::-webkit-scrollbar-thumb {
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
background-clip: content-box;
border: 3px solid transparent;
transition: background .2s ease;
}
.sp-15__box::-webkit-scrollbar-thumb:hover {
background: oklch(0.82 0.16 100);
background-clip: content-box;
}
.sp-15__box:focus-visible {
outline: 2px solid var(--sp-15-acc);
outline-offset: -2px;
}
.sp-15__lede {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__sh {
margin-block-start: 7px;
font-size: 14.6px;
font-weight: 700;
letter-spacing: -.015em;
}
.sp-15__bp {
font-size: 14.6px;
line-height: 1.68;
color: var(--sp-15-mut);
text-wrap: pretty;
max-inline-size: 62ch;
}
.sp-15__bp code,
.sp-15__p code,
.sp-15__lrow code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .85em;
background: oklch(1 0 0/.1);
padding: 2px 6px;
border-radius: 5px;
}
.sp-15__end {
margin-block-start: 8px;
padding: 11px 13px;
border-radius: 11px;
font-size: 13.6px;
color: var(--sp-15-acc);
background: color-mix(in oklch,var(--sp-15-acc) 12%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-15-acc) 26%,transparent);
}
.sp-15__legend {
display: grid;
gap: 11px;
padding: 16px;
border: 1px solid var(--sp-15-line);
border-radius: 16px;
background: color-mix(in oklch,var(--sp-15-surface) 70%,transparent);
}
.sp-15__lrow {
display: grid;
grid-template-columns: auto minmax(0,1fr);
gap: 9px;
align-items: start;
font-size: 12.6px;
line-height: 1.5;
color: var(--sp-15-mut);
}
.sp-15__sw {
inline-size: 14px;
block-size: 14px;
border-radius: 5px;
flex: none;
margin-block-start: 3px;
}
.sp-15__sw--thumb {
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
}
.sp-15__sw--track {
background: linear-gradient(180deg,var(--sp-15-acc),var(--sp-15-track));
border: 1px solid var(--sp-15-line);
}
.sp-15__sw--gut {
background: repeating-linear-gradient(45deg,oklch(1 0 0/.14) 0 3px,transparent 3px 6px);
}
.sp-15__notes {
display: grid;
gap: 12px;
padding-block: clamp(28px,5vh,56px);
}
.sp-15__kicker {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__h {
font-size: clamp(21px,3vw,31px);
line-height: 1.12;
letter-spacing: -.032em;
font-weight: 700;
text-wrap: balance;
}
.sp-15__p {
max-inline-size: 62ch;
font-size: 15.6px;
line-height: 1.7;
color: var(--sp-15-mut);
text-wrap: pretty;
}
.sp-15__chip {
justify-self: start;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--sp-15-acc);
padding: 7px 13px;
border-radius: 99px;
background: color-mix(in oklch,var(--sp-15-acc) 14%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-15-acc) 30%,transparent);
}
.sp-15__pagefoot {
display: grid;
place-items: center;
padding: clamp(40px,8vh,100px) 24px;
border-block-start: 1px solid var(--sp-15-line);
}
.sp-15__pagefootin {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
letter-spacing: .06em;
color: var(--sp-15-mut);
text-align: center;
}
.sp-15__bar,
.sp-15__intro,
.sp-15__stage,
.sp-15__notes {
padding-inline: max(clamp(18px,4vw,36px),calc((100% - 1060px)/2));
}
@media (max-width: 800px) {
.sp-15__stage {
grid-template-columns: minmax(0,1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.sp-15 * {
transition-duration: .01ms !important;
}
}.sp-15 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--sp-15-bg);
--sp-15-bg: oklch(0.16 0.014 275);
--sp-15-surface: oklch(0.21 0.018 275);
--sp-15-ink: oklch(0.97 0.004 275);
--sp-15-mut: oklch(0.72 0.016 275);
--sp-15-acc: oklch(0.74 0.15 100);
--sp-15-track: oklch(1 0 0/.08);
--sp-15-line: oklch(1 0 0/.12);
--sp-15-p: 0;
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--sp-15-ink);
-webkit-font-smoothing: antialiased;
}
.sp-15 *,
.sp-15 *::before,
.sp-15 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.sp-15__bar {
position: sticky;
inset-block-start: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 14px;
min-block-size: 58px;
background: color-mix(in oklch,var(--sp-15-bg) 78%,transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-block-end: 1px solid var(--sp-15-line);
}
.sp-15__brand {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 730;
letter-spacing: -.02em;
color: inherit;
text-decoration: none;
}
.sp-15__brand:focus-visible {
outline: 2px solid var(--sp-15-acc);
outline-offset: 3px;
border-radius: 6px;
}
.sp-15__logo {
inline-size: 7px;
block-size: 20px;
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.6 0.16 140));
}
.sp-15__meta {
margin-inline-start: auto;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .06em;
color: var(--sp-15-mut);
}
.sp-15__intro {
display: grid;
gap: 14px;
padding-block: clamp(44px,10vh,110px) clamp(22px,5vh,46px);
}
.sp-15__intro-eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__intro-title {
font-size: clamp(30px,5.4vw,62px);
line-height: 1;
letter-spacing: -.04em;
font-weight: 730;
max-inline-size: 18ch;
text-wrap: balance;
}
.sp-15__intro-sub {
font-size: clamp(14.5px,1.6vw,18px);
line-height: 1.55;
color: var(--sp-15-mut);
max-inline-size: 54ch;
text-wrap: pretty;
}
.sp-15__stage {
display: grid;
grid-template-columns: minmax(0,1fr) minmax(200px,250px);
gap: clamp(16px,3vw,32px);
align-items: start;
padding-block-end: clamp(30px,6vh,64px);
}
.sp-15__boxwrap {
position: relative;
timeline-scope: --sp-15-tl;
padding-inline-end: 18px;
}
.sp-15__gauge {
position: absolute;
inset-block: 8px;
inset-inline-end: 0;
inline-size: 6px;
border-radius: 99px;
background: var(--sp-15-track);
box-shadow: inset 0 0 0 1px var(--sp-15-line);
overflow: hidden;
pointer-events: none;
}
.sp-15__gfill {
display: block;
inline-size: 100%;
block-size: 100%;
transform-origin: 50% 0;
transform: scaleY(var(--sp-15-p));
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
}
.sp-15__box {
block-size: min(62svh,520px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--sp-15-acc) var(--sp-15-track);
padding: 20px clamp(16px,3vw,26px);
border: 1px solid var(--sp-15-line);
border-radius: 16px;
background: var(--sp-15-surface);
display: grid;
gap: 11px;
align-content: start;
}
@supports (animation-timeline: scroll()) {
.sp-15__box {
scroll-timeline-name: --sp-15-tl;
scroll-timeline-axis: block;
}
.sp-15__gfill {
animation: sp-15-track linear;
animation-timeline: --sp-15-tl;
}
}
@keyframes sp-15-track {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.sp-15__box::-webkit-scrollbar {
inline-size: 12px;
}
.sp-15__box::-webkit-scrollbar-track {
background: transparent;
}
.sp-15__box::-webkit-scrollbar-thumb {
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
background-clip: content-box;
border: 3px solid transparent;
transition: background .2s ease;
}
.sp-15__box::-webkit-scrollbar-thumb:hover {
background: oklch(0.82 0.16 100);
background-clip: content-box;
}
.sp-15__box:focus-visible {
outline: 2px solid var(--sp-15-acc);
outline-offset: -2px;
}
.sp-15__lede {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__sh {
margin-block-start: 7px;
font-size: 14.6px;
font-weight: 700;
letter-spacing: -.015em;
}
.sp-15__bp {
font-size: 14.6px;
line-height: 1.68;
color: var(--sp-15-mut);
text-wrap: pretty;
max-inline-size: 62ch;
}
.sp-15__bp code,
.sp-15__p code,
.sp-15__lrow code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .85em;
background: oklch(1 0 0/.1);
padding: 2px 6px;
border-radius: 5px;
}
.sp-15__end {
margin-block-start: 8px;
padding: 11px 13px;
border-radius: 11px;
font-size: 13.6px;
color: var(--sp-15-acc);
background: color-mix(in oklch,var(--sp-15-acc) 12%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-15-acc) 26%,transparent);
}
.sp-15__legend {
display: grid;
gap: 11px;
padding: 16px;
border: 1px solid var(--sp-15-line);
border-radius: 16px;
background: color-mix(in oklch,var(--sp-15-surface) 70%,transparent);
}
.sp-15__lrow {
display: grid;
grid-template-columns: auto minmax(0,1fr);
gap: 9px;
align-items: start;
font-size: 12.6px;
line-height: 1.5;
color: var(--sp-15-mut);
}
.sp-15__sw {
inline-size: 14px;
block-size: 14px;
border-radius: 5px;
flex: none;
margin-block-start: 3px;
}
.sp-15__sw--thumb {
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
}
.sp-15__sw--track {
background: linear-gradient(180deg,var(--sp-15-acc),var(--sp-15-track));
border: 1px solid var(--sp-15-line);
}
.sp-15__sw--gut {
background: repeating-linear-gradient(45deg,oklch(1 0 0/.14) 0 3px,transparent 3px 6px);
}
.sp-15__notes {
display: grid;
gap: 12px;
padding-block: clamp(28px,5vh,56px);
}
.sp-15__kicker {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__h {
font-size: clamp(21px,3vw,31px);
line-height: 1.12;
letter-spacing: -.032em;
font-weight: 700;
text-wrap: balance;
}
.sp-15__p {
max-inline-size: 62ch;
font-size: 15.6px;
line-height: 1.7;
color: var(--sp-15-mut);
text-wrap: pretty;
}
.sp-15__chip {
justify-self: start;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--sp-15-acc);
padding: 7px 13px;
border-radius: 99px;
background: color-mix(in oklch,var(--sp-15-acc) 14%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-15-acc) 30%,transparent);
}
.sp-15__pagefoot {
display: grid;
place-items: center;
padding: clamp(40px,8vh,100px) 24px;
border-block-start: 1px solid var(--sp-15-line);
}
.sp-15__pagefootin {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
letter-spacing: .06em;
color: var(--sp-15-mut);
text-align: center;
}
.sp-15__bar,
.sp-15__intro,
.sp-15__stage,
.sp-15__notes {
padding-inline: max(clamp(18px,4vw,36px),calc((100% - 1060px)/2));
}
@media (max-width: 800px) {
.sp-15__stage {
grid-template-columns: minmax(0,1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.sp-15 * {
transition-duration: .01ms !important;
}
}(() => {
const root = document.querySelector('.sp-15');
if (!root || root.dataset.spWired) return;
root.dataset.spWired = '1';
const box = root.querySelector('#sp-15-box');
const gauge = root.querySelector('#sp-15-gauge');
let ticking = false;
const paint = () => {
ticking = false;
const max = box.scrollHeight - box.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, box.scrollTop / max)) : 0;
root.style.setProperty('--sp-15-p', p.toFixed(4));
gauge.setAttribute('aria-valuenow', String(Math.round(p * 100)));
};
box.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
addEventListener('resize', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
paint();
})();(() => {
const root = document.querySelector('.sp-15');
if (!root || root.dataset.spWired) return;
root.dataset.spWired = '1';
const box = root.querySelector('#sp-15-box');
const gauge = root.querySelector('#sp-15-gauge');
let ticking = false;
const paint = () => {
ticking = false;
const max = box.scrollHeight - box.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, box.scrollTop / max)) : 0;
root.style.setProperty('--sp-15-p', p.toFixed(4));
gauge.setAttribute('aria-valuenow', String(Math.round(p * 100)));
};
box.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
addEventListener('resize', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { 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: Custom ::-webkit-scrollbar with Progress Indicator
Source: https://codefronts.com/motion/css-scroll-progress-bar/custom-webkit-scrollbar-with-progress-indicator/
Two scrollbar systems exist and neither is going away: WebKit's shadow pseudo-elements and the standard scrollbar-width/scrollbar-color pair. This demo ships both correctly — then shows why the popular 'fill the scrollbar track with a gradient' trick cannot work (backgrounds stop at the padding box) and replaces it with a six-pixel gauge that reads the panel's own scroll timeline.
## HTML
```html
<section class="sp-15" aria-label="Custom scrollbar with progress indicator demo">
<header class="sp-15__bar">
<a class="sp-15__brand" href="#sp-15-box"><span class="sp-15__logo" aria-hidden="true"></span>Gutter</a>
<p class="sp-15__meta">::-webkit-scrollbar + scrollbar-color</p>
</header>
<div class="sp-15__intro">
<p class="sp-15__intro-eyebrow">two systems, both required</p>
<h2 class="sp-15__intro-title">A scrollbar that doubles as a progress bar</h2>
<p class="sp-15__intro-sub">Scroll inside the panel. The scrollbar is styled in both systems, and the six-pixel gauge beside it reads the panel's own scroll timeline.</p>
</div>
<div class="sp-15__stage">
<div class="sp-15__boxwrap">
<div class="sp-15__box" id="sp-15-box" tabindex="0" role="region" aria-label="Release notes, scrollable">
<p class="sp-15__lede">Release notes · v4.2</p>
<h4 class="sp-15__sh">Standard first</h4>
<p class="sp-15__bp"><code>scrollbar-width</code> and <code>scrollbar-color</code> are the interoperable properties. They are coarse — three widths and two colours — but they are honoured by Firefox and increasingly by everyone else.</p>
<h4 class="sp-15__sh">WebKit for the detail</h4>
<p class="sp-15__bp">Radii, gradients, inset padding and hover growth all live in the pseudo-elements. Layer them on top rather than instead.</p>
<h4 class="sp-15__sh">The inset thumb trick</h4>
<p class="sp-15__bp">A transparent border plus <code>background-clip: content-box</code> is how the thumb appears to float inside the track. The border reserves space the background never paints into.</p>
<h4 class="sp-15__sh">The progress layer</h4>
<p class="sp-15__bp">You cannot paint it inside the scrollbar. A background's positioning area stops at the padding box, so a gradient at <code>right top</code> lands under the text padding — and overlay or 2px scrollbars leave nothing to see through. The gauge to the right is a real element on this panel's named timeline.</p>
<h4 class="sp-15__sh">Gutter stability</h4>
<p class="sp-15__bp"><code>scrollbar-gutter: stable</code> reserves the space up front so text does not reflow the moment content grows past one screen.</p>
<h4 class="sp-15__sh">Never hide it</h4>
<p class="sp-15__bp">A hidden scrollbar removes the only cue that content continues and takes away a drag target some users depend on. Hide it only where another indicator exists.</p>
<h4 class="sp-15__sh">Grab size</h4>
<p class="sp-15__bp">Below roughly 8 by 24 pixels a thumb stops being grabbable. Thin is a style; invisible is a bug.</p>
<h4 class="sp-15__sh">Platform truth</h4>
<p class="sp-15__bp">iOS draws overlay scrollbars at the system level and ignores your styling entirely. Treat scrollbar design as decoration, never as the only signal.</p>
<p class="sp-15__end">End of notes — the gauge beside the scrollbar is now full.</p>
</div>
<div class="sp-15__gauge" role="progressbar" aria-label="Release notes read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="sp-15-gauge"><i class="sp-15__gfill"></i></div>
</div>
<div class="sp-15__legend">
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--thumb"></span><span>Thumb · <code>scrollbar-color</code> arg 1</span></p>
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--track"></span><span>Gauge · a real element, bound to the panel's named scroll timeline</span></p>
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--gut"></span><span>Gutter · reserved by <code>scrollbar-gutter</code></span></p>
</div>
</div>
<section class="sp-15__notes">
<p class="sp-15__kicker">Coverage</p>
<h3 class="sp-15__h">Write both, always — and don't trust either for meaning</h3>
<p class="sp-15__p">WebKit pseudo-elements will never land in Firefox, and the standard properties cannot express a gradient thumb. Shipping only one leaves a large slice of users with a bar that does not match the design — and iOS ignores both, drawing its own overlay bar. Anything that has to <em>communicate</em> position belongs in an element you control.</p>
<p class="sp-15__chip">2 systems · 1 gauge · 0 hidden scrollbars</p>
</section>
<footer class="sp-15__pagefoot"><p class="sp-15__pagefootin">Scroll progress pattern 15 of 18 · codefronts.com</p></footer>
</section>
```
## CSS
```css
.sp-15 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--sp-15-bg);
--sp-15-bg: oklch(0.16 0.014 275);
--sp-15-surface: oklch(0.21 0.018 275);
--sp-15-ink: oklch(0.97 0.004 275);
--sp-15-mut: oklch(0.72 0.016 275);
--sp-15-acc: oklch(0.74 0.15 100);
--sp-15-track: oklch(1 0 0/.08);
--sp-15-line: oklch(1 0 0/.12);
--sp-15-p: 0;
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--sp-15-ink);
-webkit-font-smoothing: antialiased;
}
.sp-15 *,
.sp-15 *::before,
.sp-15 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.sp-15__bar {
position: sticky;
inset-block-start: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 14px;
min-block-size: 58px;
background: color-mix(in oklch,var(--sp-15-bg) 78%,transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-block-end: 1px solid var(--sp-15-line);
}
.sp-15__brand {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 730;
letter-spacing: -.02em;
color: inherit;
text-decoration: none;
}
.sp-15__brand:focus-visible {
outline: 2px solid var(--sp-15-acc);
outline-offset: 3px;
border-radius: 6px;
}
.sp-15__logo {
inline-size: 7px;
block-size: 20px;
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.6 0.16 140));
}
.sp-15__meta {
margin-inline-start: auto;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .06em;
color: var(--sp-15-mut);
}
.sp-15__intro {
display: grid;
gap: 14px;
padding-block: clamp(44px,10vh,110px) clamp(22px,5vh,46px);
}
.sp-15__intro-eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__intro-title {
font-size: clamp(30px,5.4vw,62px);
line-height: 1;
letter-spacing: -.04em;
font-weight: 730;
max-inline-size: 18ch;
text-wrap: balance;
}
.sp-15__intro-sub {
font-size: clamp(14.5px,1.6vw,18px);
line-height: 1.55;
color: var(--sp-15-mut);
max-inline-size: 54ch;
text-wrap: pretty;
}
.sp-15__stage {
display: grid;
grid-template-columns: minmax(0,1fr) minmax(200px,250px);
gap: clamp(16px,3vw,32px);
align-items: start;
padding-block-end: clamp(30px,6vh,64px);
}
.sp-15__boxwrap {
position: relative;
timeline-scope: --sp-15-tl;
padding-inline-end: 18px;
}
.sp-15__gauge {
position: absolute;
inset-block: 8px;
inset-inline-end: 0;
inline-size: 6px;
border-radius: 99px;
background: var(--sp-15-track);
box-shadow: inset 0 0 0 1px var(--sp-15-line);
overflow: hidden;
pointer-events: none;
}
.sp-15__gfill {
display: block;
inline-size: 100%;
block-size: 100%;
transform-origin: 50% 0;
transform: scaleY(var(--sp-15-p));
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
}
.sp-15__box {
block-size: min(62svh,520px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--sp-15-acc) var(--sp-15-track);
padding: 20px clamp(16px,3vw,26px);
border: 1px solid var(--sp-15-line);
border-radius: 16px;
background: var(--sp-15-surface);
display: grid;
gap: 11px;
align-content: start;
}
@supports (animation-timeline: scroll()) {
.sp-15__box {
scroll-timeline-name: --sp-15-tl;
scroll-timeline-axis: block;
}
.sp-15__gfill {
animation: sp-15-track linear;
animation-timeline: --sp-15-tl;
}
}
@keyframes sp-15-track {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.sp-15__box::-webkit-scrollbar {
inline-size: 12px;
}
.sp-15__box::-webkit-scrollbar-track {
background: transparent;
}
.sp-15__box::-webkit-scrollbar-thumb {
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
background-clip: content-box;
border: 3px solid transparent;
transition: background .2s ease;
}
.sp-15__box::-webkit-scrollbar-thumb:hover {
background: oklch(0.82 0.16 100);
background-clip: content-box;
}
.sp-15__box:focus-visible {
outline: 2px solid var(--sp-15-acc);
outline-offset: -2px;
}
.sp-15__lede {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__sh {
margin-block-start: 7px;
font-size: 14.6px;
font-weight: 700;
letter-spacing: -.015em;
}
.sp-15__bp {
font-size: 14.6px;
line-height: 1.68;
color: var(--sp-15-mut);
text-wrap: pretty;
max-inline-size: 62ch;
}
.sp-15__bp code,
.sp-15__p code,
.sp-15__lrow code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .85em;
background: oklch(1 0 0/.1);
padding: 2px 6px;
border-radius: 5px;
}
.sp-15__end {
margin-block-start: 8px;
padding: 11px 13px;
border-radius: 11px;
font-size: 13.6px;
color: var(--sp-15-acc);
background: color-mix(in oklch,var(--sp-15-acc) 12%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-15-acc) 26%,transparent);
}
.sp-15__legend {
display: grid;
gap: 11px;
padding: 16px;
border: 1px solid var(--sp-15-line);
border-radius: 16px;
background: color-mix(in oklch,var(--sp-15-surface) 70%,transparent);
}
.sp-15__lrow {
display: grid;
grid-template-columns: auto minmax(0,1fr);
gap: 9px;
align-items: start;
font-size: 12.6px;
line-height: 1.5;
color: var(--sp-15-mut);
}
.sp-15__sw {
inline-size: 14px;
block-size: 14px;
border-radius: 5px;
flex: none;
margin-block-start: 3px;
}
.sp-15__sw--thumb {
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
}
.sp-15__sw--track {
background: linear-gradient(180deg,var(--sp-15-acc),var(--sp-15-track));
border: 1px solid var(--sp-15-line);
}
.sp-15__sw--gut {
background: repeating-linear-gradient(45deg,oklch(1 0 0/.14) 0 3px,transparent 3px 6px);
}
.sp-15__notes {
display: grid;
gap: 12px;
padding-block: clamp(28px,5vh,56px);
}
.sp-15__kicker {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__h {
font-size: clamp(21px,3vw,31px);
line-height: 1.12;
letter-spacing: -.032em;
font-weight: 700;
text-wrap: balance;
}
.sp-15__p {
max-inline-size: 62ch;
font-size: 15.6px;
line-height: 1.7;
color: var(--sp-15-mut);
text-wrap: pretty;
}
.sp-15__chip {
justify-self: start;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--sp-15-acc);
padding: 7px 13px;
border-radius: 99px;
background: color-mix(in oklch,var(--sp-15-acc) 14%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-15-acc) 30%,transparent);
}
.sp-15__pagefoot {
display: grid;
place-items: center;
padding: clamp(40px,8vh,100px) 24px;
border-block-start: 1px solid var(--sp-15-line);
}
.sp-15__pagefootin {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
letter-spacing: .06em;
color: var(--sp-15-mut);
text-align: center;
}
.sp-15__bar,
.sp-15__intro,
.sp-15__stage,
.sp-15__notes {
padding-inline: max(clamp(18px,4vw,36px),calc((100% - 1060px)/2));
}
@media (max-width: 800px) {
.sp-15__stage {
grid-template-columns: minmax(0,1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.sp-15 * {
transition-duration: .01ms !important;
}
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.sp-15');
if (!root || root.dataset.spWired) return;
root.dataset.spWired = '1';
const box = root.querySelector('#sp-15-box');
const gauge = root.querySelector('#sp-15-gauge');
let ticking = false;
const paint = () => {
ticking = false;
const max = box.scrollHeight - box.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, box.scrollTop / max)) : 0;
root.style.setProperty('--sp-15-p', p.toFixed(4));
gauge.setAttribute('aria-valuenow', String(Math.round(p * 100)));
};
box.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
addEventListener('resize', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { 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: Custom ::-webkit-scrollbar with Progress Indicator
Source: https://codefronts.com/motion/css-scroll-progress-bar/custom-webkit-scrollbar-with-progress-indicator/
Two scrollbar systems exist and neither is going away: WebKit's shadow pseudo-elements and the standard scrollbar-width/scrollbar-color pair. This demo ships both correctly — then shows why the popular 'fill the scrollbar track with a gradient' trick cannot work (backgrounds stop at the padding box) and replaces it with a six-pixel gauge that reads the panel's own scroll timeline.
## HTML
```html
<section class="sp-15" aria-label="Custom scrollbar with progress indicator demo">
<header class="sp-15__bar">
<a class="sp-15__brand" href="#sp-15-box"><span class="sp-15__logo" aria-hidden="true"></span>Gutter</a>
<p class="sp-15__meta">::-webkit-scrollbar + scrollbar-color</p>
</header>
<div class="sp-15__intro">
<p class="sp-15__intro-eyebrow">two systems, both required</p>
<h2 class="sp-15__intro-title">A scrollbar that doubles as a progress bar</h2>
<p class="sp-15__intro-sub">Scroll inside the panel. The scrollbar is styled in both systems, and the six-pixel gauge beside it reads the panel's own scroll timeline.</p>
</div>
<div class="sp-15__stage">
<div class="sp-15__boxwrap">
<div class="sp-15__box" id="sp-15-box" tabindex="0" role="region" aria-label="Release notes, scrollable">
<p class="sp-15__lede">Release notes · v4.2</p>
<h4 class="sp-15__sh">Standard first</h4>
<p class="sp-15__bp"><code>scrollbar-width</code> and <code>scrollbar-color</code> are the interoperable properties. They are coarse — three widths and two colours — but they are honoured by Firefox and increasingly by everyone else.</p>
<h4 class="sp-15__sh">WebKit for the detail</h4>
<p class="sp-15__bp">Radii, gradients, inset padding and hover growth all live in the pseudo-elements. Layer them on top rather than instead.</p>
<h4 class="sp-15__sh">The inset thumb trick</h4>
<p class="sp-15__bp">A transparent border plus <code>background-clip: content-box</code> is how the thumb appears to float inside the track. The border reserves space the background never paints into.</p>
<h4 class="sp-15__sh">The progress layer</h4>
<p class="sp-15__bp">You cannot paint it inside the scrollbar. A background's positioning area stops at the padding box, so a gradient at <code>right top</code> lands under the text padding — and overlay or 2px scrollbars leave nothing to see through. The gauge to the right is a real element on this panel's named timeline.</p>
<h4 class="sp-15__sh">Gutter stability</h4>
<p class="sp-15__bp"><code>scrollbar-gutter: stable</code> reserves the space up front so text does not reflow the moment content grows past one screen.</p>
<h4 class="sp-15__sh">Never hide it</h4>
<p class="sp-15__bp">A hidden scrollbar removes the only cue that content continues and takes away a drag target some users depend on. Hide it only where another indicator exists.</p>
<h4 class="sp-15__sh">Grab size</h4>
<p class="sp-15__bp">Below roughly 8 by 24 pixels a thumb stops being grabbable. Thin is a style; invisible is a bug.</p>
<h4 class="sp-15__sh">Platform truth</h4>
<p class="sp-15__bp">iOS draws overlay scrollbars at the system level and ignores your styling entirely. Treat scrollbar design as decoration, never as the only signal.</p>
<p class="sp-15__end">End of notes — the gauge beside the scrollbar is now full.</p>
</div>
<div class="sp-15__gauge" role="progressbar" aria-label="Release notes read" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="sp-15-gauge"><i class="sp-15__gfill"></i></div>
</div>
<div class="sp-15__legend">
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--thumb"></span><span>Thumb · <code>scrollbar-color</code> arg 1</span></p>
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--track"></span><span>Gauge · a real element, bound to the panel's named scroll timeline</span></p>
<p class="sp-15__lrow"><span class="sp-15__sw sp-15__sw--gut"></span><span>Gutter · reserved by <code>scrollbar-gutter</code></span></p>
</div>
</div>
<section class="sp-15__notes">
<p class="sp-15__kicker">Coverage</p>
<h3 class="sp-15__h">Write both, always — and don't trust either for meaning</h3>
<p class="sp-15__p">WebKit pseudo-elements will never land in Firefox, and the standard properties cannot express a gradient thumb. Shipping only one leaves a large slice of users with a bar that does not match the design — and iOS ignores both, drawing its own overlay bar. Anything that has to <em>communicate</em> position belongs in an element you control.</p>
<p class="sp-15__chip">2 systems · 1 gauge · 0 hidden scrollbars</p>
</section>
<footer class="sp-15__pagefoot"><p class="sp-15__pagefootin">Scroll progress pattern 15 of 18 · codefronts.com</p></footer>
</section>
```
## CSS
```css
.sp-15 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--sp-15-bg);
--sp-15-bg: oklch(0.16 0.014 275);
--sp-15-surface: oklch(0.21 0.018 275);
--sp-15-ink: oklch(0.97 0.004 275);
--sp-15-mut: oklch(0.72 0.016 275);
--sp-15-acc: oklch(0.74 0.15 100);
--sp-15-track: oklch(1 0 0/.08);
--sp-15-line: oklch(1 0 0/.12);
--sp-15-p: 0;
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--sp-15-ink);
-webkit-font-smoothing: antialiased;
}
.sp-15 *,
.sp-15 *::before,
.sp-15 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.sp-15__bar {
position: sticky;
inset-block-start: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 14px;
min-block-size: 58px;
background: color-mix(in oklch,var(--sp-15-bg) 78%,transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-block-end: 1px solid var(--sp-15-line);
}
.sp-15__brand {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 730;
letter-spacing: -.02em;
color: inherit;
text-decoration: none;
}
.sp-15__brand:focus-visible {
outline: 2px solid var(--sp-15-acc);
outline-offset: 3px;
border-radius: 6px;
}
.sp-15__logo {
inline-size: 7px;
block-size: 20px;
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.6 0.16 140));
}
.sp-15__meta {
margin-inline-start: auto;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .06em;
color: var(--sp-15-mut);
}
.sp-15__intro {
display: grid;
gap: 14px;
padding-block: clamp(44px,10vh,110px) clamp(22px,5vh,46px);
}
.sp-15__intro-eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__intro-title {
font-size: clamp(30px,5.4vw,62px);
line-height: 1;
letter-spacing: -.04em;
font-weight: 730;
max-inline-size: 18ch;
text-wrap: balance;
}
.sp-15__intro-sub {
font-size: clamp(14.5px,1.6vw,18px);
line-height: 1.55;
color: var(--sp-15-mut);
max-inline-size: 54ch;
text-wrap: pretty;
}
.sp-15__stage {
display: grid;
grid-template-columns: minmax(0,1fr) minmax(200px,250px);
gap: clamp(16px,3vw,32px);
align-items: start;
padding-block-end: clamp(30px,6vh,64px);
}
.sp-15__boxwrap {
position: relative;
timeline-scope: --sp-15-tl;
padding-inline-end: 18px;
}
.sp-15__gauge {
position: absolute;
inset-block: 8px;
inset-inline-end: 0;
inline-size: 6px;
border-radius: 99px;
background: var(--sp-15-track);
box-shadow: inset 0 0 0 1px var(--sp-15-line);
overflow: hidden;
pointer-events: none;
}
.sp-15__gfill {
display: block;
inline-size: 100%;
block-size: 100%;
transform-origin: 50% 0;
transform: scaleY(var(--sp-15-p));
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
}
.sp-15__box {
block-size: min(62svh,520px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--sp-15-acc) var(--sp-15-track);
padding: 20px clamp(16px,3vw,26px);
border: 1px solid var(--sp-15-line);
border-radius: 16px;
background: var(--sp-15-surface);
display: grid;
gap: 11px;
align-content: start;
}
@supports (animation-timeline: scroll()) {
.sp-15__box {
scroll-timeline-name: --sp-15-tl;
scroll-timeline-axis: block;
}
.sp-15__gfill {
animation: sp-15-track linear;
animation-timeline: --sp-15-tl;
}
}
@keyframes sp-15-track {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.sp-15__box::-webkit-scrollbar {
inline-size: 12px;
}
.sp-15__box::-webkit-scrollbar-track {
background: transparent;
}
.sp-15__box::-webkit-scrollbar-thumb {
border-radius: 99px;
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
background-clip: content-box;
border: 3px solid transparent;
transition: background .2s ease;
}
.sp-15__box::-webkit-scrollbar-thumb:hover {
background: oklch(0.82 0.16 100);
background-clip: content-box;
}
.sp-15__box:focus-visible {
outline: 2px solid var(--sp-15-acc);
outline-offset: -2px;
}
.sp-15__lede {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__sh {
margin-block-start: 7px;
font-size: 14.6px;
font-weight: 700;
letter-spacing: -.015em;
}
.sp-15__bp {
font-size: 14.6px;
line-height: 1.68;
color: var(--sp-15-mut);
text-wrap: pretty;
max-inline-size: 62ch;
}
.sp-15__bp code,
.sp-15__p code,
.sp-15__lrow code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .85em;
background: oklch(1 0 0/.1);
padding: 2px 6px;
border-radius: 5px;
}
.sp-15__end {
margin-block-start: 8px;
padding: 11px 13px;
border-radius: 11px;
font-size: 13.6px;
color: var(--sp-15-acc);
background: color-mix(in oklch,var(--sp-15-acc) 12%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-15-acc) 26%,transparent);
}
.sp-15__legend {
display: grid;
gap: 11px;
padding: 16px;
border: 1px solid var(--sp-15-line);
border-radius: 16px;
background: color-mix(in oklch,var(--sp-15-surface) 70%,transparent);
}
.sp-15__lrow {
display: grid;
grid-template-columns: auto minmax(0,1fr);
gap: 9px;
align-items: start;
font-size: 12.6px;
line-height: 1.5;
color: var(--sp-15-mut);
}
.sp-15__sw {
inline-size: 14px;
block-size: 14px;
border-radius: 5px;
flex: none;
margin-block-start: 3px;
}
.sp-15__sw--thumb {
background: linear-gradient(180deg,var(--sp-15-acc),oklch(0.62 0.16 140));
}
.sp-15__sw--track {
background: linear-gradient(180deg,var(--sp-15-acc),var(--sp-15-track));
border: 1px solid var(--sp-15-line);
}
.sp-15__sw--gut {
background: repeating-linear-gradient(45deg,oklch(1 0 0/.14) 0 3px,transparent 3px 6px);
}
.sp-15__notes {
display: grid;
gap: 12px;
padding-block: clamp(28px,5vh,56px);
}
.sp-15__kicker {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--sp-15-acc);
}
.sp-15__h {
font-size: clamp(21px,3vw,31px);
line-height: 1.12;
letter-spacing: -.032em;
font-weight: 700;
text-wrap: balance;
}
.sp-15__p {
max-inline-size: 62ch;
font-size: 15.6px;
line-height: 1.7;
color: var(--sp-15-mut);
text-wrap: pretty;
}
.sp-15__chip {
justify-self: start;
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--sp-15-acc);
padding: 7px 13px;
border-radius: 99px;
background: color-mix(in oklch,var(--sp-15-acc) 14%,transparent);
border: 1px solid color-mix(in oklch,var(--sp-15-acc) 30%,transparent);
}
.sp-15__pagefoot {
display: grid;
place-items: center;
padding: clamp(40px,8vh,100px) 24px;
border-block-start: 1px solid var(--sp-15-line);
}
.sp-15__pagefootin {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 12px;
letter-spacing: .06em;
color: var(--sp-15-mut);
text-align: center;
}
.sp-15__bar,
.sp-15__intro,
.sp-15__stage,
.sp-15__notes {
padding-inline: max(clamp(18px,4vw,36px),calc((100% - 1060px)/2));
}
@media (max-width: 800px) {
.sp-15__stage {
grid-template-columns: minmax(0,1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.sp-15 * {
transition-duration: .01ms !important;
}
}
```
## JavaScript
```js
(() => {
const root = document.querySelector('.sp-15');
if (!root || root.dataset.spWired) return;
root.dataset.spWired = '1';
const box = root.querySelector('#sp-15-box');
const gauge = root.querySelector('#sp-15-gauge');
let ticking = false;
const paint = () => {
ticking = false;
const max = box.scrollHeight - box.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, box.scrollTop / max)) : 0;
root.style.setProperty('--sp-15-p', p.toFixed(4));
gauge.setAttribute('aria-valuenow', String(Math.round(p * 100)));
};
box.addEventListener('scroll', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
addEventListener('resize', () => { if (!ticking) { ticking = true; requestAnimationFrame(paint); } }, { passive: true });
paint();
})();
```How this works
Write the standard properties first, because they are the future and they are what Firefox honours:
.scroller{
scrollbar-width: thin; /* auto | thin | none */
scrollbar-color: var(--thumb) var(--track); /* thumb then track */
scrollbar-gutter: stable;
}Then layer the WebKit pseudo-elements for the detail the standard properties cannot express — radii, gradients, inset padding, hover growth:
.scroller::-webkit-scrollbar{ width: 10px }
.scroller::-webkit-scrollbar-track{ background: var(--track) }
.scroller::-webkit-scrollbar-thumb{
border-radius: 99px; background: var(--thumb);
border: 3px solid transparent; background-clip: content-box; /* inset look */
}The transparent-border-plus-background-clip:content-box trick is how every polished custom scrollbar gets its floating, inset thumb — the border reserves space the background never paints into.
The progress part deliberately does not try to paint inside the scrollbar. That is the trap this demo exists to document: an element's background positioning area is its padding box, which excludes the scrollbar gutter — so a gradient positioned at right top lands under the text's right padding, not behind the thumb. Add overlay scrollbars (iOS, macOS trackpads) or scrollbar-width: thin rendering a 2px bar, and there is nothing to see through at all.
The reliable version is a real element beside the scroller, bound to the panel's own named timeline:
.wrap{ position:relative; timeline-scope: --panel }
.scroller{ scroll-timeline-name: --panel; scroll-timeline-axis: block }
.gauge__fill{
transform-origin: 50% 0;
animation: sp-fill linear;
animation-timeline: --panel; /* the gauge is a SIBLING — hence timeline-scope */
}Six pixels, deterministic on every platform, and it survives a browser that draws its own overlay scrollbar over everything.
Because scrollbar styling is one of the last places where a real capability difference remains, the whole block is branched on @supports selector(::-webkit-scrollbar). Browsers that support the pseudo-elements get the detailed treatment; everyone else gets the standard properties, which look deliberate rather than default.
One accessibility rule overrides all of it: never set scrollbar-width: none on a container users must scroll unless another affordance is present. Hiding the scrollbar removes the only indication that content continues, and it removes a drag target that some motor-impaired users rely on.
Make it yours
- Width:
::-webkit-scrollbar{ width }andscrollbar-width: thin. Keep the thumb at least 8px wide or it becomes an unusable drag target. - Auto-hide on idle: transition the thumb's opacity and restore it on
:hoverof the scroller — but only for content where the scrollbar is not the only scroll cue. - Horizontal too: the same pseudo-elements apply, and
::-webkit-scrollbar-cornerhandles the junction of both bars. - Dark mode: the two custom properties are all that change;
scrollbar-colortakes them directly. - Page-level scrollbar: the same rules on
htmlstyle the document scrollbar — on Windows especially, this is a large, cheap branding win.
Gotchas — read before shipping
- An element's background positioning area is its padding box, which excludes the scrollbar gutter — so you cannot paint a progress fill 'behind the thumb' with a background gradient. It lands under the content padding instead. Use a real element beside the scroller.
- A gauge rendered as a sibling of the scroller cannot see a
scroll-timeline-namedeclared on it withouttimeline-scopeon a shared ancestor. ::-webkit-scrollbaris not standard and Firefox will never support it. Writing only the WebKit rules leaves a third of your users with the default bar.- Setting
::-webkit-scrollbar{ display:none }orscrollbar-width:nonehides the only signal that a region scrolls. Use it only for carousels with their own indicator (see demo 11). - The two systems can conflict: if both are present, WebKit uses its pseudo-elements and ignores
scrollbar-color. Keep them visually in sync manually. - Without
scrollbar-gutter: stable, content jumps sideways the moment a list grows past one screen — especially visible in overlay-scrollbar environments. - A thumb smaller than about 8×24px is very hard to grab, and shrinking it is the most common regression in custom scrollbar designs.
- Styling the document scrollbar on iOS does nothing — overlay scrollbars are drawn by the system. Never depend on it for information.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 115+ | 26+ | 144+ | 115+ |
::-webkit-scrollbar works in Chrome, Edge, Safari and Opera; scrollbar-width/scrollbar-color are Firefox 64+, Chrome 121+ and Safari 18.2+. Writing both is the only complete answer. The progress fill needs a scroll timeline and falls back to a custom property.