Scroll progress bar for articles: CSS-first, minimal JS that won’t page-fault your UX
You shipped a beautiful longform piece. Then analytics says readers “bounce” after 12 seconds. Maybe they’re bored. Or maybe they’re lost. A tiny progress bar won’t save bad writing, but it will stop good writing from looking like an endless hallway with no exits.
The trap: most progress bars are built like a junior SRE built a cron job—works in the happy path, melts under load, and lies quietly about edge cases. Let’s build one that behaves in real browsers, on real phones, with real performance budgets.
What you’re actually building (and why it breaks)
A scroll progress bar sounds like UI garnish. It isn’t. It’s a live telemetry display driven by one of the highest-frequency “signals” in the browser: scrolling. If you wire it wrong, you don’t just get a slightly inaccurate bar. You get:
- Jank: frames missed because you force layout on every scroll tick.
- Battery drain: mobile devices doing extra work for a decoration.
- Incorrect progress: because you measured the wrong thing (document height is not article height).
- Layout shift: because your bar changes layout instead of painting.
- Accessibility regressions: because you’ve created state that screen readers can’t interpret, or you’ve obscured controls at the top of the page.
The trick is to separate concerns like you would in a production system:
- Measurement is the only part that needs JavaScript.
- Rendering is CSS’s job: a fixed/sticky element and a transform/width based on a single variable.
- Scheduling is where bugs breed: you want one update per frame at most, not 80 per second because someone got excited about scroll events.
Rule: treat scroll progress like a metrics pipeline. Sampling rate, accuracy, and cost matter. Don’t “just update the DOM”. That’s how you get a UI that looks like it’s buffering.
Facts and a little history (because the web loves repeating mistakes)
Some context makes better decisions. Here are concrete points that explain why “simple” scroll UI so often goes sideways:
- Scroll events used to fire at wildly different rates across browsers; many implementations were effectively “best effort” and coupled to the main thread’s health.
- Mobile browsers introduced asynchronous scrolling (scrolling on a compositor thread) to feel smooth even when JavaScript is busy; this made naive scroll-driven effects less reliable.
- Early “reading progress” widgets often used jQuery plus
$(window).scroll(), which encouraged layout reads/writes in the same handler. It worked—until content got long. position: stickytook years to become boring across browsers; before that, many sites used JS to simulate sticky headers, which doubled the amount of scroll work.- CLS (Cumulative Layout Shift) became a formal metric in the Web Vitals era, forcing teams to care about tiny layout changes—like a progress bar that pushes content down.
- IntersectionObserver was introduced to reduce the need for scroll event polling for visibility detection. It’s not a silver bullet for progress bars, but it’s a useful tool for some “article-only” measurements.
- CSS custom properties made it practical to pipe a single numeric value from JS into multiple CSS effects without touching the DOM structure.
- Safari’s dynamic viewport units and address bar behaviors repeatedly surprised teams; measuring scroll ranges based on viewport size is more subtle than it looks.
And yes, we’re still doing scroll-driven UI in 2025. The difference is whether you do it like a grown-up.
Requirements that matter in production
If you’re building this for a real publication, internal wiki, docs portal, or marketing site with long technical articles, set requirements up front. Otherwise your “tiny bar” becomes a reliability ticket farm.
Functional requirements
- Measures progress through the article content, not the entire document (nav, footer, comments, related links are noise).
- Handles dynamic content: images loading late, embeds expanding, code blocks toggling, font swaps.
- Works in nested scroll containers if your layout uses them (common in “docs app” shells).
- Doesn’t block input: the top of the page often has controls, breadcrumbs, back buttons. Your bar must not intercept clicks.
Non-functional requirements
- Minimal main-thread work: ideally one calculation per animation frame while scrolling, and nothing while idle.
- No layout thrash: avoid patterns that force sync layout (read layout then write layout repeatedly).
- Stable layout: the bar should overlay, not reflow content (unless your design explicitly reserves space).
- Graceful fallback: if JS fails, the page still reads fine; the bar can simply be empty.
- Accessible semantics: don’t make it “ARIA spam”, but don’t hide meaningful state if you present it as information.
“paraphrased idea”: You build it, you run it — ownership means caring about operability, not just shipping features.Werner Vogels (paraphrased idea)
CSS-first architecture: make CSS do the boring work
CSS is good at two things that matter here: placement and paint. Let it do both. Your progress bar element should be dumb: a container pinned to the top, and a child that visually fills based on a single numeric value.
Pick the right positioning model
You basically have two sane choices:
position: stickyon a top-of-document wrapper. Good when your header area participates in layout and you want the bar to scroll away if the header does.position: fixedfor a bar always visible. Good when you want it immune to ancestor overflow quirks and you don’t care about layout context.
I default to sticky when the bar is part of the article chrome and you reserve its height. I default to fixed when the site has complicated app-shell containers, transforms, or overflow rules that make sticky behave like a moody cat.
Don’t animate layout if you can animate paint
You’ll see implementations that do width: X%. That’s not automatically bad, but it can cause more layout work than you want depending on surrounding constraints. A safer approach is to render a full-width bar and scale it:
- Set the fill element to
width: 100%. - Use
transform: scaleX(var(--progress))withtransform-origin: left.
Transforms are typically compositor-friendly. Typically. You still need to measure and verify.
Opinion Use transform: scaleX() unless you have a design reason not to. It’s harder to accidentally trigger layout, and easier to optimize.
Keep the progress bar from stealing clicks
If your bar is overlaying the top, it can intercept clicks on header buttons. Add:
pointer-events: noneon the progress shell, unless you explicitly need interactivity.
This is the sort of bug that ships because nobody clicks the top-left “back” button during QA. Users do. Users always do.
CSS example (rendering only)
We already included a sticky bar in this page. The important part is the contract: CSS reads a custom property called --progress in the range [0, 1]. Everything else is styling.
Minimal JS: one job, one variable, no drama
JavaScript’s job is to compute progress and set --progress. That’s it. No DOM churn. No innerHTML. No querying a dozen elements on every scroll tick.
What “progress” should mean
There are three common definitions. Pick one deliberately:
- Document progress: how far you’ve scrolled through the whole page. Easy, but misleading when there’s a giant footer or comments section.
- Article progress by top/bottom: 0% when the article top hits the viewport top; 100% when the article bottom hits the viewport bottom (or top). This matches “I’m done reading”.
- Article progress by line-of-sight: based on a marker (e.g., current heading). Harder, but can be useful in docs with navigation.
This piece focuses on #2 because it’s honest and stable.
Scheduling: requestAnimationFrame, not raw scroll spam
Scroll events can fire fast and irregular. If you calculate and set CSS on every event, you risk doing extra work, and you risk interleaving reads/writes badly.
Pattern that behaves:
- Listen for scroll (passive).
- On scroll, schedule one
requestAnimationFrameif not already scheduled. - In rAF, read what you must, compute progress, write one CSS variable.
- Also update on resize and on content changes that affect layout.
Minimal JS implementation
Drop this at the end of the body (or in a deferred script). It assumes your article container is <main id="content"> or a more specific <article> you choose.
cr0x@server:~$ cat progress.js
(() => {
const root = document.documentElement;
const target = document.querySelector("main#content") || document.body;
let ticking = false;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function compute() {
ticking = false;
const rect = target.getBoundingClientRect();
const viewport = window.innerHeight || root.clientHeight;
// Progress definition:
// 0 when target top is at top of viewport
// 1 when target bottom is at bottom of viewport
const total = rect.height - viewport;
let p;
if (total <= 0) {
// Content shorter than viewport: "done"
p = 1;
} else {
p = (-rect.top) / total;
}
root.style.setProperty("--progress", clamp01(p).toFixed(4));
}
function requestTick() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
// Passive scroll listener: don't block scrolling
window.addEventListener("scroll", requestTick, { passive: true });
window.addEventListener("resize", requestTick);
// Update once on load
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", compute, { once: true });
} else {
compute();
}
// Watch for layout changes that affect height (images, embeds, font swaps)
if ("ResizeObserver" in window) {
const ro = new ResizeObserver(requestTick);
ro.observe(target);
}
})();
That’s the “minimal JS” bar. It’s not zero JS. It’s the correct amount of JS: one measurement loop, one CSS variable write, one animation frame. Anything else needs justification.
Joke #1: If your progress bar needs a state machine, you’re not tracking reading progress—you’re building a space program.
Why this computation works
getBoundingClientRect() gives you the target element’s top relative to the viewport. When you scroll down, rect.top becomes negative. The denominator rect.height - viewport is the total scrollable distance required for the element to go from “top aligned” to “bottom aligned”.
Edge cases handled:
- Short content: if the article fits in the viewport, progress is 1. You can choose 0 if you prefer “no scroll equals no progress,” but that tends to look broken.
- Overscroll / bounce: clamped to
[0,1]so iOS elastic scrolling doesn’t show negative progress. - Dynamic content height: ResizeObserver triggers recalculation when the article height changes.
Variants: document scroll, container scroll, and “real article” scroll
Variant A: whole-document progress (easy, often wrong)
If your page is basically an article and a tiny footer, whole-document progress is acceptable. The computation is simple: scrollTop divided by (scrollHeight – clientHeight). It’s also the one most likely to lie when you add a “Related articles” panel the size of a novella.
cr0x@server:~$ cat document-progress.js
(() => {
const root = document.documentElement;
let ticking = false;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function compute() {
ticking = false;
const scrollTop = root.scrollTop || document.body.scrollTop;
const max = root.scrollHeight - root.clientHeight;
const p = max > 0 ? scrollTop / max : 1;
root.style.setProperty("--progress", clamp01(p).toFixed(4));
}
function requestTick() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
window.addEventListener("scroll", requestTick, { passive: true });
window.addEventListener("resize", requestTick);
compute();
})();
Use it when your layout is simple and stable. Otherwise, measure the article, not the universe.
Variant B: scroll container progress (docs app shells)
Many corporate docs sites put the reading pane in a scroll container while the sidebar stays fixed. Window scroll events won’t move. Your progress bar needs to listen to the container and measure the container’s scrollTop.
Key gotcha: position: sticky and position: fixed behave differently inside overflow containers. If your app shell uses overflow: hidden on body and a scrolling div, prefer a progress bar attached to the scrolling container, not the window.
cr0x@server:~$ cat container-progress.js
(() => {
const root = document.documentElement;
const scroller = document.querySelector("[data-scroll-container]");
if (!scroller) return;
let ticking = false;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function compute() {
ticking = false;
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? scroller.scrollTop / max : 1;
root.style.setProperty("--progress", clamp01(p).toFixed(4));
}
function requestTick() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
scroller.addEventListener("scroll", requestTick, { passive: true });
window.addEventListener("resize", requestTick);
compute();
})();
Variant C: “real article” progress with start/end offsets
Sometimes you want progress to start after the hero image, or end before the newsletter signup. You can add “sentinel” elements—one at the start and one at the end—and compute progress based on their positions. This is more robust than trying to guess offsets with magic numbers.
Sentinels are also easy to reason about: you can inspect them and move them without rewriting math.
Accessibility and UX: progress is information
A progress bar is a visual hint. For some readers, it’s also a decision tool: “Do I have time to finish this?” If you treat it as purely decorative, fine—but then keep it aria-hidden and don’t pretend it’s a control.
When to expose it to assistive tech
If the bar is just a thin line at the top, exposing it as a live-updating progressbar role is usually noise. Screen readers don’t need a constant stream of “32%, 33%, 34%” while the user scrolls. That’s like a coworker narrating your commute.
Better options:
- Decorative only: keep
aria-hidden="true"on the progress element, as in the example. - Expose on demand: provide a “Reading progress: 42%” text label in a toolbar that updates at low frequency (e.g., when scrolling stops) or only when focused.
- Use it as part of navigation: if you also provide “jump to section” controls, then progress becomes a legitimate UI component with semantics.
Color, contrast, and “don’t be cute” design
A progress bar is not a rainbow. Your primary job is legibility against both light and dark backgrounds, and not clashing with your header. Use a subtle background track and a strong fill color. Test with forced colors modes if you support them.
Respect reduced motion
A progress bar isn’t usually a motion issue, but some designs add easing or bounce. Don’t. A scroll indicator should track scroll. If you add lag, you create distrust. Users hate dishonest meters; ask anyone who has watched a stuck progress spinner.
Performance model: why scroll handlers jank
Scrolling feels smooth when the browser can produce frames on time (roughly 60fps on many devices, 120fps on newer ones). Your progress bar update competes with everything else: style recalculation, layout, paint, scripting, images decoding, and whatever third-party analytics decided to do today.
The two big performance failure modes
1) Forced synchronous layout
If your handler reads layout (like getBoundingClientRect) after writing something that invalidates layout (like changing widths or classes), the browser may have to flush layout immediately. That can happen per scroll tick. Congratulations, you invented a jank generator.
2) Too many updates
Even if each update is “fast,” doing it 200 times in a second can still be slow. requestAnimationFrame caps you at one update per frame, and lets the browser schedule work sensibly.
Why CSS-first helps
By keeping rendering in CSS and writing one custom property, you reduce DOM mutations and style invalidation. You also make the code auditable. When a future teammate adds an extra querySelectorAll in the scroll loop, it’s obvious they’re about to cause harm.
What about “scroll-driven animations” CSS?
Modern CSS has scroll-driven animation primitives in some browsers. They’re promising, especially because they can move work off the main thread. But cross-browser behavior and product constraints still make a minimal JS approach more portable today.
If you can use pure CSS scroll-linked animations reliably in your supported browser set, do it. Otherwise, treat it like IPv6: correct, inevitable, and still full of sharp edges when you least want them.
Practical tasks: 12+ checks with commands, outputs, and decisions
You want a progress bar that behaves. That means you test like an operator, not like a demo artist. Below are real tasks with commands, sample outputs, what they mean, and the decision you make.
Task 1: Verify your HTML reserves no unexpected layout space
cr0x@server:~$ rg -n "progress-shell|bar-height|position: fixed|position: sticky" -S ./dist
dist/app.css:42:.progress-shell { position: sticky; top: 0; height: var(--bar-height); }
dist/index.html:12:<div class="progress-shell" aria-hidden="true">
Output meaning: You confirm how the bar is positioned and whether it participates in layout. Sticky with explicit height means you’ve reserved space (no CLS from insertion). Fixed often overlays (no layout impact) but can overlap header UI.
Decision: If you see fixed without accounting for header, add top padding or set pointer-events: none to avoid blocking interactions.
Task 2: Validate the CSS variable is being set at runtime
cr0x@server:~$ node -e "console.log('Check in DevTools: document.documentElement.style.getPropertyValue(\"--progress\")')"
Check in DevTools: document.documentElement.style.getPropertyValue("--progress")
Output meaning: This reminds you what to check: the variable must be set on documentElement (or a known scope) and must be numeric.
Decision: If it’s empty or “NaN”, your JS didn’t run, selector didn’t match, or computation divided by zero.
Task 3: Confirm your script is loaded with defer or at end of body
cr0x@server:~$ rg -n "<script.*progress(\.js)?|defer" ./dist/index.html
35:<script src="/assets/progress.js" defer></script>
Output meaning: Using defer prevents blocking parsing and ensures DOM exists by execution time.
Decision: If it’s not deferred and it’s in head, move it or add defer. Scroll UI shouldn’t delay first render.
Task 4: Check for accidental scroll listeners added by frameworks or plugins
cr0x@server:~$ rg -n "addEventListener\\(\"scroll\"|onscroll|wheel\\)|IntersectionObserver" ./dist -S
dist/assets/progress.js:18:window.addEventListener("scroll", requestTick, { passive: true });
dist/assets/vendor.js:9912:window.addEventListener("scroll", onScroll);
Output meaning: There’s another scroll listener in vendor code. That may be fine, or it may be your real jank source.
Decision: Audit the extra handler. If it reads layout or writes styles per event, fix or throttle it. Don’t blame the progress bar for someone else’s crime.
Task 5: Ensure scroll listeners are passive
cr0x@server:~$ rg -n "addEventListener\\(\"scroll\".*passive" ./dist/assets/progress.js
22:window.addEventListener("scroll", requestTick, { passive: true });
Output meaning: Passive scroll listeners tell the browser you won’t call preventDefault(), so scrolling can stay smooth.
Decision: If passive is missing, add it unless you truly need to cancel scroll (you don’t for a progress bar).
Task 6: Detect layout shifts caused by inserting the bar late
cr0x@server:~$ rg -n "document\\.createElement\\(|insertBefore\\(|prepend\\(" ./src -S
src/progress-init.js:4:document.body.prepend(shell);
Output meaning: You’re injecting the bar dynamically. That often causes CLS because it changes layout after paint.
Decision: Prefer server-rendered markup for the bar or reserve space with CSS before injecting. If you must inject, insert a placeholder of identical height early.
Task 7: Identify whether you’re measuring the correct element (article vs page)
cr0x@server:~$ rg -n "querySelector\\(\"(article|main|\\#content|\\.post)\"\\)" ./src/progress.js
3: const target = document.querySelector("main#content") || document.body;
Output meaning: This measurement ties progress to main#content. If your site wraps nav + footer inside main, progress becomes misleading.
Decision: Change selector to a real article container (article, [data-article]), or add start/end sentinels.
Task 8: Check that your progress bar doesn’t overlap interactive header elements
cr0x@server:~$ rg -n "pointer-events" ./dist/app.css
58:.progress-shell { position: sticky; top: 0; z-index: 999; height: var(--bar-height); background: var(--bar-bg); box-shadow: var(--shadow); }
Output meaning: No pointer-events: none found.
Decision: Add pointer-events: none to the shell unless it’s interactive. This prevents “why can’t I click the logo” tickets.
Task 9: Confirm that your CSS animation/transition isn’t lying
cr0x@server:~$ rg -n "transition:|animation:" ./dist/app.css
Output meaning: No transitions. Good: the bar tracks actual scroll position without lag.
Decision: If you find easing transitions on width/transform, remove them or restrict them. Progress indicators should be accurate, not cinematic.
Task 10: Find main-thread long tasks during scroll (quick local check)
cr0x@server:~$ node -e "console.log('Use Chrome DevTools Performance: record a scroll, then look for Long Task markers > 50ms in Main.')"
Use Chrome DevTools Performance: record a scroll, then look for Long Task markers > 50ms in Main.
Output meaning: This is the operator prompt: record, scroll, then inspect. If the main thread is blocked, your bar can’t update smoothly.
Decision: If you see long tasks during scroll, find whether they’re from your handler, font rendering, syntax highlighting, or third-party scripts. Fix the biggest block first.
Task 11: Check if images or embeds are changing article height after load
cr0x@server:~$ rg -n "<img |loading=|width=|height=" ./dist/index.html
88:<img src="/assets/hero.webp" loading="lazy">
Output meaning: The image is lazy-loaded but may not have width/height attributes. That can cause layout shifts when it loads.
Decision: Add width/height (or CSS aspect-ratio) so layout is stable. Your progress computation relies on element height; unstable height means jumpy progress.
Task 12: Confirm ResizeObserver support or choose a fallback
cr0x@server:~$ node -e "console.log('If you support older browsers, gate ResizeObserver and also recompute on load + font load events.')"
If you support older browsers, gate ResizeObserver and also recompute on load + font load events.
Output meaning: ResizeObserver is broadly supported, but if you have a strict legacy policy, you need a fallback strategy.
Decision: Without ResizeObserver, update on load, resize, and maybe after fonts load (or just accept small inaccuracies).
Task 13: Confirm your progress bar is not causing extra paints
cr0x@server:~$ node -e "console.log('In DevTools Rendering, enable Paint flashing and scroll. The bar should repaint cheaply, not trigger full-page flashes.')"
In DevTools Rendering, enable Paint flashing and scroll. The bar should repaint cheaply, not trigger full-page flashes.
Output meaning: Paint flashing reveals whether your updates invalidate huge regions.
Decision: If the entire header repaints every frame, simplify effects (drop heavy box-shadows/filters) or move the bar to its own layer (carefully; layers can also cost memory).
Task 14: Verify container-scroll implementations are measuring the right scroll root
cr0x@server:~$ rg -n "scrollTop|scrollHeight|clientHeight|data-scroll-container" ./src -S
src/container-progress.js:12: const max = scroller.scrollHeight - scroller.clientHeight;
Output meaning: You’re using the container’s own scroll metrics, not the window’s. Good for app shells.
Decision: If progress never changes, your container may not be the scroll root. Identify the actual scrolling element and attach there.
Fast diagnosis playbook
When someone says “the progress bar is laggy” or “it jumps,” don’t debate aesthetics. Run a quick triage like you would for a latency spike.
First: confirm measurement correctness
- Is the right element being measured? Inspect the selector and the bounding rect. If it’s measuring the entire body while the actual reading happens in a nested container, your numbers are junk.
- Is the scroll range stable? If images/embeds load late and change height, your total scroll range changes mid-read.
- Is progress clamped? Elastic scrolling and overscroll can produce negative or >1 values. If you see the bar “wrap,” you forgot to clamp.
Second: find the bottleneck class
- Main thread blocked? Look for long tasks during scroll recording. If yes, your bar is innocent; it’s reporting a broken system.
- Layout thrash? Check if your handler reads layout and writes layout in the same frame repeatedly. Minimize DOM reads, batch writes.
- Paint too heavy? Paint flashing tells you if your bar triggers expensive repaints. Gradients are usually fine; filters and big shadows often aren’t.
Third: check browser-specific weirdness
- Safari address bar / dynamic viewport? Progress jumps at the top/bottom can be viewport height changes. Consider using more robust measurements and recompute on resize/orientation changes.
- Overflow + sticky interaction? If the bar disappears or sticks incorrectly, check ancestor
overflowand transforms.
Joke #2: A scroll progress bar is basically a tiny SLA: the moment it lies, users open a mental incident ticket.
Common mistakes: symptoms → root cause → fix
Here’s the gritty list. If your bar does something weird, it’s probably one of these.
1) Bar jumps backward during scroll
Symptoms: You scroll down; bar briefly decreases or flickers.
Root cause: Total scroll range changed mid-scroll due to images/embeds loading, fonts swapping, or collapsible components changing height.
Fix: Reserve space for media (width/height or aspect-ratio). Add ResizeObserver (as shown) and recompute using the latest rect. Avoid measuring a container that is itself reflowing due to sticky headers inserted late.
2) Bar reaches 100% before the article ends
Symptoms: You hit 100% while still reading, especially if the footer is tall or there’s a “related content” block.
Root cause: Measuring the wrong element—document progress instead of article progress.
Fix: Measure a dedicated article container or use start/end sentinels placed precisely where “reading starts/ends.”
3) Bar never moves in a docs app layout
Symptoms: Progress stuck at 0% even when scrolling the reading pane.
Root cause: The scroll happens in a nested container, not the window. Your listener is on window.
Fix: Attach listener to the container, compute progress using scrollTop/scrollHeight/clientHeight.
4) Top navigation becomes unclickable
Symptoms: Users can’t click header buttons near the top edge; it works if they scroll slightly.
Root cause: A fixed/sticky overlay element is intercepting pointer events.
Fix: Add pointer-events: none to the progress shell or reposition it to avoid overlapping interactive elements.
5) Scroll performance tanks on mobile
Symptoms: Progress updates lag, scrolling feels heavy, battery drains faster.
Root cause: Non-passive listeners, too much work in the handler, frequent forced layouts, or heavy paints (filters, shadows).
Fix: Use passive listeners, rAF scheduling, one DOM write per frame, avoid expensive visual effects, and remove extra scroll listeners doing layout reads.
6) Bar is inaccurate when the address bar collapses/expands (mobile Safari)
Symptoms: Progress changes without scrolling or jumps near top.
Root cause: Viewport height changes dynamically; your denominator depends on viewport height.
Fix: Recompute on resize (already). If it’s still jumpy, use a scroll container with stable height or treat small viewport deltas as noise (debounce resize updates).
7) CLS regression when the bar appears
Symptoms: Content shifts down after the page loads.
Root cause: The bar element is injected after initial paint without reserved space.
Fix: Render the bar in initial HTML, reserve its height, or overlay it with fixed positioning so it doesn’t affect layout.
Three corporate mini-stories from the land of “it worked on my MacBook”
Mini-story 1: The incident caused by a wrong assumption
They had a content platform with long-form articles and a shiny redesign. Someone added a “reading progress” bar tied to document.documentElement.scrollTop and called it a day. In QA, the bar looked great. The pages were short test fixtures with a stub footer.
Then production happened. Real pages had a comment system that loaded after the main content, plus “related” cards that expanded when an A/B test variant was enabled. Users would reach 100% progress while still halfway through the article, because the denominator (scroll height) changed after the progress calculation had already assumed a stable total.
The support tickets were priceless: “Your site says I finished reading, but I’m not done.” People don’t usually file tickets about a thin blue line. They did because it undermined trust. It also confused internal analytics teams who used “100% reached” as a proxy for completion.
The fix was not complicated, which made it more embarrassing. They switched to measuring the actual article container, added width/height to images, and updated progress on layout changes via ResizeObserver. The bigger fix was cultural: stop assuming the document is the content. In modern sites, the document is a junk drawer.
Mini-story 2: The optimization that backfired
Another team got performance religion and decided to “optimize” the progress bar by caching measurements. They calculated the article height once on DOMContentLoaded, stored it, and updated progress using that constant. They even removed the expensive getBoundingClientRect() call from the scroll path. It benchmarked faster on a fast laptop. Everyone felt clever.
Then the font loaded. Text reflowed, line heights changed, and the article’s height grew. On some pages with code blocks, syntax highlighting also ran after initial paint and changed layout. The progress bar drifted. At 80% scroll, it reported 95%. At the end, it never hit 100%. On mobile, it was worse because the viewport height changed when the URL bar collapsed, and their cached denominator didn’t account for it.
They “fixed” it by recalculating every 250ms on a timer, which promptly turned into a battery-eating metronome. The next sprint was spent backing out the optimization and replacing it with a ResizeObserver + rAF update loop—exactly the approach they had originally dismissed as “too much.”
Lesson: caching is not an optimization if the underlying value changes. That’s not caching; it’s lying with confidence.
Mini-story 3: The boring but correct practice that saved the day
A docs team did something unsexy: they wrote down acceptance criteria for the progress bar. It had to track the reading pane (not the window), it had to work with embedded diagrams, it had to not interfere with header controls, and it had to degrade gracefully when JavaScript failed.
They also set up a performance budget: the progress update work had to stay under a small slice of a frame on a mid-tier phone. They didn’t guess. They recorded scroll performance with DevTools on representative pages and kept the traces in a regression folder. When someone changed the header CSS and added a heavy blur, paint time spiked during scroll and the bar started stuttering. The trace made the culprit obvious.
Because the bar was CSS-first and JS-minimal, the fix was mostly design: remove the blur, simplify shadows, and isolate the progress layer. The bar remained a single CSS variable update. No rewrites, no late-night patching.
Boring practices—measurement, budgets, representative test pages—are what keep tiny UI features from becoming permanent operational debt.
Checklists / step-by-step plan
If you want a plan you can actually execute without a week of meetings, here it is.
Checklist A: Build the component (CSS-first)
- Add a progress shell element at the top of the document (or inside the scroll container).
- Style it with
position: stickyorfixed. Pick one intentionally. - Make the fill a child element that scales based on
--progress. - Set
pointer-events: noneunless you need it interactive. - Ensure the bar height is reserved if it’s part of layout (or overlay it if you want zero layout effect).
Checklist B: Wire the minimal JS update loop
- Choose your measurement target: article element or scroll container.
- Implement rAF-scheduled updates; do not update per scroll event directly.
- Clamp progress to
[0, 1]. - Write a single CSS variable to
documentElement(or a known scope). - Update on
resizeand on content size changes (ResizeObserver if available).
Checklist C: Validate behavior on “real content”
- Test a short article (fits in viewport): progress should read as complete or behave according to your chosen rule.
- Test an article with many images and embeds: progress should not jump backward after assets load.
- Test a page with a huge footer or related content: progress should reflect the article, not the page.
- Test on mobile Safari: scroll to trigger address bar collapse/expand; watch for weird jumps.
- Test keyboard navigation: the bar should not hide focus outlines or block controls.
Checklist D: Performance checks before you ship
- Record a scroll performance trace and look for long tasks > 50ms.
- Enable paint flashing to see whether the bar triggers large repaints.
- Check that there’s only one scroll-driven update loop (or that multiples are justified).
- Verify passive event listeners.
- Verify no CLS from late injection.
FAQ
1) Can I do a scroll progress bar with zero JavaScript?
Sometimes, in some browsers, using scroll-driven CSS animations. If you need broad support and predictable behavior, minimal JS is still the pragmatic choice. Zero JS is a nice headline, not always a stable system.
2) Should I use width or transform: scaleX()?
Use transform: scaleX() by default. It generally avoids layout work and tends to be smoother. Use width if your design requires it and you’ve verified it doesn’t trigger expensive reflow in your layout.
3) Why not update the progress bar directly in the scroll event?
Because scroll events can fire more often than your frame budget can tolerate. rAF lets you update at most once per frame and keeps reads/writes grouped. It’s the difference between sampling a signal and trying to react to every electron.
4) How do I make progress track only the article, not comments and related links?
Measure a dedicated article container element, or place explicit start/end sentinel elements around the content you consider “reading.” Avoid “document height” if your pages have variable appendages.
5) What’s the best approach for single-page apps with nested scrolling panes?
Attach your listener to the actual scroll container and compute progress from its scrollTop and scrollHeight. Window scroll is often a no-op in SPA shells that lock body scrolling.
6) Does ResizeObserver cause performance issues?
It can if you observe too much or do heavy work in the callback. Here, you observe one element and merely schedule a rAF update. That’s cheap and appropriate. Avoid observing large subtrees for this feature.
7) My bar flickers on pages with collapsible code blocks. Why?
Because the content height changes while you scroll. Ensure your progress recalculates after expansions (ResizeObserver helps). Also ensure the code block component isn’t doing expensive reflows during scroll.
8) Should the bar show 0% at the top or a tiny non-zero value?
Show 0% until the article actually starts. If your layout has a hero section above the article, define start precisely (sentinel) or users will see “progress” before they’ve read anything, which feels like a gas meter that drops while parked.
9) How do I avoid the bar covering my sticky header shadow?
Make a decision about layering: either the bar is part of the header chrome (inside it), or it overlays above it. Don’t let z-index wars happen by accident. Assign a clear stacking context.
10) Do I need to debounce resize events?
Not usually if resize work is light and scheduled via rAF. If you see resize storms (orientation changes, viewport UI changes), you can add a simple debounce, but start by measuring. Guessing is how you invent bugs.
Conclusion: next steps you can ship
Build the progress bar like you build reliable systems: constrain responsibilities, measure the real thing, and don’t create work where none is needed.
- Decide what “progress” means for your product (document vs article vs sentinel-defined reading range).
- Render the bar with CSS using a single
--progresscustom property. - Update that property with minimal JS: passive scroll listener + rAF + clamp + ResizeObserver.
- Test on pages with real-world content (late-loading images, embeds, long code blocks).
- Run the fast diagnosis playbook once before launch, then keep a performance trace around for regression hunting.
If you do it this way, the progress bar becomes what it should be: a quiet, honest indicator. Not a source of new incidents. Your on-call rotation deserves at least that.