Web Animations Easy
Scroll Progress Indicator
Expose document progress with scroll() and a JS fallback for long-form content.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--line: #2b3d62;
--text: #eef2ff;
--muted: #9eb1d4;
--accent: #78a9ff;
--accent-2: #b78bff;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: clamp(1rem, 4vw, 2.5rem);
}
.demo {
width: min(760px, 100%);
margin: auto;
background: var(--panel);
border: 1px solid #263555;
border-radius: 22px;
padding: clamp(1rem, 3vw, 1.75rem);
box-shadow: 0 20px 70px #0004;
}
/* ---------- top bar + progress ---------- */
.topbar {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.9rem;
margin-bottom: 1rem;
}
.brand {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--muted);
white-space: nowrap;
}
.progress {
height: 6px;
border-radius: 99px;
background: #263555;
overflow: hidden;
}
.progress__fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
box-shadow: 0 0 14px #78a9ff70;
transform-origin: 0 50%;
transform: scaleX(var(--p, 0));
transition: transform 90ms linear;
}
.pct {
font-variant-numeric: tabular-nums;
font-size: 0.78rem;
color: var(--muted);
min-width: 4ch;
text-align: right;
}
/* Native scroll-driven path (JS steps aside when this applies).
* `scroll(nearest)` would resolve against the *fill's* own ancestors — the fill
* lives in .topbar, outside #scroller, so it would bind to the root scroller and
* never move. Instead #scroller declares a NAMED timeline and .demo exposes it to
* its whole subtree via timeline-scope, so the fill can reference it by name. */
@supports (animation-timeline: scroll()) and (timeline-scope: --x) {
.demo { timeline-scope: --article-scroll; }
.scroller { scroll-timeline: --article-scroll block; }
.progress__fill {
transition: none;
transform: scaleX(0);
animation: grow linear both;
animation-timeline: --article-scroll;
}
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* ---------- scroller ---------- */
.scroller {
position: relative;
height: 58vh;
min-height: 300px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding: 0.25rem 1.1rem 0.25rem 2.2rem;
border: 1px solid var(--line);
border-radius: 16px;
background: #18233b;
}
.scroller:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* ---------- section rail ---------- */
.rail {
position: absolute;
left: 0.75rem;
top: 0;
width: 8px;
pointer-events: none;
}
.rail::before {
content: "";
position: absolute;
left: 3px;
top: 0;
bottom: 0;
width: 2px;
background: #ffffff14;
}
.rail__dot {
position: absolute;
left: 0;
width: 8px;
height: 8px;
margin-top: -4px;
border-radius: 50%;
background: #3c4c72;
transition: background 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
.rail__dot[data-passed="true"] {
background: var(--accent);
transform: scale(1.35);
box-shadow: 0 0 10px #78a9ff80;
}
/* ---------- article ---------- */
.article h1 { font-size: 1.3rem; line-height: 1.25; margin: 1rem 0 0.6rem; }
.article h2 { font-size: 0.95rem; margin: 1.75rem 0 0.4rem; color: var(--accent); }
.article p { margin: 0 0 0.7rem; font-size: 0.9rem; color: #c8d2ea; }
.lede { color: var(--text) !important; font-size: 0.98rem !important; }
.end { color: var(--muted) !important; font-style: italic; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
background: #ffffff12;
padding: 1px 5px;
border-radius: 5px;
}
.hint, .mode { margin: 0.75rem 0 0; font-size: 0.78rem; color: var(--muted); }
.mode { color: #6d7ea3; margin-top: 0.25rem; }
@media (prefers-reduced-motion: reduce) {
.progress__fill, .rail__dot { transition: none; }
.scroller { scroll-behavior: auto; }
}/* Scroll Progress Indicator
* Native scroll-driven CSS timeline when supported, passive JS fallback otherwise.
* Both paths feed the same progressbar semantics + section rail. */
(() => {
const scroller = document.getElementById("scroller");
const article = document.getElementById("article");
const bar = document.getElementById("progressbar");
const fill = document.getElementById("progressFill");
const pct = document.getElementById("pct");
const rail = document.getElementById("rail");
const modeEl = document.getElementById("mode");
// Every node this script touches must exist — bail out wholesale otherwise.
if (!scroller || !article || !bar || !fill || !pct || !rail || !modeEl) return;
// Both features are required: the named timeline on #scroller and the
// timeline-scope that exports it to the bar outside the scroller.
const nativeTimeline =
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("animation-timeline: scroll()") &&
CSS.supports("timeline-scope: --x");
modeEl.textContent = nativeTimeline
? "Bar driven by CSS animation-timeline: scroll() — JS only updates ARIA + markers."
: "CSS scroll timelines unsupported here — JS fallback driving the bar.";
/* ---- cached metrics (never read layout during scroll) ---- */
let maxScroll = 0;
let dots = [];
function buildRail() {
rail.textContent = "";
const sections = article.querySelectorAll("section");
// Only write when it actually changes: this node lives inside the observed
// #scroller subtree, and an unconditional write would re-trigger the
// ResizeObserver ("loop completed with undelivered notifications").
const total = (article.offsetHeight || 1) + "px";
if (rail.style.height !== total) rail.style.height = total;
dots = [...sections].map((section) => {
const dot = document.createElement("span");
dot.className = "rail__dot";
dot.dataset.passed = "false";
dot.style.top = section.offsetTop + "px";
rail.appendChild(dot);
return { el: dot, offset: section.offsetTop };
});
}
function measure() {
maxScroll = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
buildRail();
update();
}
/* ---- per-frame update, rAF-throttled ---- */
let ticking = false;
function update() {
const top = scroller.scrollTop;
const ratio = maxScroll > 0 ? Math.min(1, Math.max(0, top / maxScroll)) : 0;
// The CSS timeline owns the transform when it is available.
if (!nativeTimeline) fill.style.setProperty("--p", ratio.toFixed(4));
const percent = Math.round(ratio * 100);
if (bar.getAttribute("aria-valuenow") !== String(percent)) {
bar.setAttribute("aria-valuenow", String(percent));
bar.setAttribute("aria-valuetext", percent + " percent read");
pct.textContent = percent + "%";
}
const threshold = top + scroller.clientHeight * 0.35;
for (const dot of dots) {
const passed = threshold >= dot.offset ? "true" : "false";
if (dot.el.dataset.passed !== passed) dot.el.dataset.passed = passed;
}
}
scroller.addEventListener(
"scroll",
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
update();
});
},
{ passive: true }
);
/* ---- keyboard fallback (scroller is focusable) ---- */
scroller.addEventListener("keydown", (e) => {
const page = scroller.clientHeight * 0.9;
const map = {
PageDown: page,
PageUp: -page,
ArrowDown: 60,
ArrowUp: -60,
Home: -Infinity,
End: Infinity,
};
if (!(e.key in map)) return;
e.preventDefault();
const delta = map[e.key];
const next =
delta === Infinity ? maxScroll : delta === -Infinity ? 0 : scroller.scrollTop + delta;
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
scroller.scrollTo({ top: next, behavior: reduced ? "auto" : "smooth" });
});
if ("ResizeObserver" in window) {
// One observer, two targets — two observers would each schedule their own
// measure() pass for the same layout change.
const ro = new ResizeObserver(measure);
ro.observe(scroller);
ro.observe(article);
} else {
addEventListener("resize", measure);
}
measure();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scroll Progress Indicator</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Scroll Progress Indicator">
<header class="topbar">
<span class="brand">Long-form article</span>
<div
class="progress"
id="progressbar"
role="progressbar"
aria-label="Reading progress"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<div class="progress__fill" id="progressFill"></div>
</div>
<output class="pct" id="pct">0%</output>
</header>
<div class="scroller" id="scroller" tabindex="0" role="region" aria-label="Article body, scrollable">
<nav class="rail" id="rail" aria-hidden="true"></nav>
<article class="article" id="article">
<h1>Scroll-linked progress, done twice</h1>
<p class="lede">
A progress bar driven by <code>animation-timeline: scroll()</code> where the browser
supports it, and by a passive scroll listener everywhere else.
</p>
<section>
<h2>Why two paths</h2>
<p>Scroll-driven animations run off the main thread, so the bar tracks the scroller
without jank even during heavy layout work. Support is still uneven, so the JavaScript
path keeps the same visual contract as a fallback.</p>
<p>Both paths write the same <code>scaleX</code>, so only one is ever active. The script
checks for both <code>animation-timeline</code> and <code>timeline-scope</code> support and
steps aside when the native timeline exists. The bar sits outside the scroller, so the
scroller declares a <em>named</em> timeline (<code>scroll-timeline: --article-scroll</code>)
and a shared ancestor exports it with <code>timeline-scope</code> — <code>scroll(nearest)</code>
would bind to the page instead.</p>
</section>
<section>
<h2>Accessibility</h2>
<p>The bar is a real <code>progressbar</code> with a live <code>aria-valuenow</code>, so
assistive tech gets the same signal sighted users do.</p>
<p>The scroller is focusable, so the keyboard can drive it with Page Up, Page Down, Home
and End — the pattern never depends on a pointer.</p>
</section>
<section>
<h2>Section markers</h2>
<p>The rail on the left marks each section start. Markers light up as they are passed,
giving a coarse table of contents with no extra chrome.</p>
<p>Offsets are measured once and re-measured on resize, so they stay correct when the
article reflows.</p>
</section>
<section>
<h2>Performance</h2>
<p>The fallback listener is passive and only writes a CSS custom property. No layout is
read during scroll — heights are cached and invalidated by a ResizeObserver.</p>
<p>That keeps the handler well under a millisecond even on very long documents.</p>
</section>
<section>
<h2>Wrapping up</h2>
<p>Keep the fallback. Keep the semantics. The rest is styling.</p>
<p class="end">— end of article —</p>
</section>
</article>
</div>
<p class="hint">Scroll the panel, or focus it and use Page Up / Page Down / Home / End.</p>
<p class="mode" id="mode"></p>
</main>
<script src="script.js"></script>
</body>
</html>import { useEffect, useState } from "react";
export function ScrollProgressIndicator() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const onScroll = () =>
setProgress(
Math.min(
100,
Math.round((scrollY / Math.max(1, document.body.scrollHeight - innerHeight)) * 100)
)
);
addEventListener("scroll", onScroll, { passive: true });
return () => removeEventListener("scroll", onScroll);
}, []);
return (
<section className="demo">
<h2>Scroll Progress Indicator</h2>
<div className="progress">
<i style={{ width: `${progress}%` }} />
</div>
<div style={{ minHeight: 420, paddingTop: 120 }}>
<article className="card">Scroll-linked content · {progress}%</article>
</div>
</section>
);
}Scroll Progress Indicator
Expose document progress with scroll() and a JS fallback for long-form content.
Support notes
The CSS uses animation-timeline where available and a small JavaScript fallback where it is not. Keep the fallback because scroll-linked CSS support varies by browser.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.