Build the reading-progress bar with a CSS scroll-driven animation (animation-timeline: view() on the article, or scroll() on the page) where supported, and fall back to an IntersectionObserver that turns the article's intersection into progress — per section for a stepped bar, or with a threshold array for a smooth one.
Problem / Scenario Context
A long-read publication wants a thin bar at the top of the screen that fills as the reader progresses through the article — not the whole page, which includes a large footer, related stories and comments. The existing bar is driven by a scroll listener that divides scrollY by the page height, so it reaches only 60% at the end of the article, and it runs on every scroll event on every page.
Two better mechanisms exist, and which one to use depends on browser support. The Scroll-Driven Effects & Sticky Headers topic covers scroll effects in general; CSS scroll-driven animations vs IntersectionObserver explains the division of labour.
Mechanics Explanation
Progress through an element can be defined as: 0 when the article's top reaches the top of the viewport, 1 when its bottom reaches the bottom of the viewport. In between, progress is how far the article has scrolled relative to how far it can scroll while overlapping the viewport.
CSS scroll-driven animation. animation-timeline: view() binds an animation to an element's journey through the scrollport. With animation-range: contain — the phase in which the element fully covers or is fully within the scrollport — an article taller than the viewport produces exactly the progress definition above. The animation runs on the compositor with no script. The bar can live anywhere if the timeline is named and shared through timeline-scope.
IntersectionObserver fallback. An observer cannot report continuous scroll position, but it can report crossings. Two workable approximations:
- Stepped: observe each section (or paragraph) of the article; progress is the fraction of sections whose top has passed a line. Accurate at section boundaries, stepped in between — often what readers want anyway.
- Smooth-ish: observe a tall, fixed-position probe or the article with a dense threshold array and derive progress from
intersectionRectandboundingClientRect. Smoother, but every threshold crossing is a main-thread task.
Comparison Table: Progress Techniques
| Technique | Measures | Main-thread work while scrolling | Smoothness | Support |
|---|---|---|---|---|
scrollY / page height listener |
whole page | every event | smooth | everywhere |
animation-timeline: scroll(root) |
whole page | none | smooth | newer engines |
animation-timeline: view() on the article |
the article | none | smooth | newer engines |
| IO per section (stepped) | the article | one task per section boundary | stepped | everywhere |
| IO with 100 thresholds | the article | one task per 1% | nearly smooth | everywhere, costly |
Minimal Reproducible Example
const bar = document.querySelector<HTMLElement>('.progress')!;
addEventListener('scroll', () => {
const p = scrollY / (document.documentElement.scrollHeight - innerHeight);
bar.style.transform = `scaleX(${p})`;
}, { passive: true });
Scroll to the last paragraph of the article: the bar shows about 60%, because the footer and comments are counted.
Production-Safe Solution
.progress {
position: fixed; inset: 0 0 auto 0; block-size: 3px; z-index: 30;
background: var(--color-primary);
transform-origin: 0 50%;
transform: scaleX(0);
}
/* Modern path: the article's view timeline drives the bar, no script. */
@supports (animation-timeline: view()) {
body { timeline-scope: --article; }
article.story { view-timeline: --article block; }
.progress {
animation: grow linear both;
animation-timeline: --article;
animation-range: contain 0% contain 100%;
}
@keyframes grow { to { transform: scaleX(1); } }
}
@media (prefers-reduced-motion: reduce) {
.progress { transition: none; }
}
// Fallback: stepped progress through the article's sections.
export function sectionProgress(article: HTMLElement, bar: HTMLElement): () => void {
if (CSS.supports('animation-timeline: view()')) return () => {};
const sections = [...article.querySelectorAll<HTMLElement>(':scope > h2, :scope > section')];
if (!sections.length) return () => {};
const passed = new Set<Element>();
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
// "Passed" = the section's top is above the reading line.
const above = e.boundingClientRect.top < (e.rootBounds?.top ?? 0);
above ? passed.add(e.target) : passed.delete(e.target);
}
bar.style.transform = `scaleX(${passed.size / sections.length})`;
bar.setAttribute('aria-valuenow', String(Math.round((passed.size / sections.length) * 100)));
}, { rootMargin: '0px 0px -70% 0px' }); // reading line at 30% from the top
sections.forEach((s) => io.observe(s));
return () => io.disconnect();
}
<div class="progress" role="progressbar" aria-label="Reading progress"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"></div>
The fallback runs a callback only when a section's top crosses the reading line, so a long article produces a few dozen callbacks in total. A short CSS transition on transform smooths the steps visually for readers who have not asked for reduced motion.
Accessibility of Progress Indicators
A reading progress bar is decorative for most sighted users and meaningless to screen-reader users unless exposed. Options:
- Treat it as decorative:
aria-hidden="true", and do not update ARIA values. Simplest, and appropriate when the article already has a table of contents or headings to navigate by. - Expose it as a progressbar with
aria-valuenow, as above, updated at section boundaries — never on every scroll frame, which would produce continuous announcements in some screen readers. - Do not announce changes through a live region. Progress through an article is not a status message.
The CSS-driven bar cannot update ARIA attributes, so if you expose it as a progressbar, run the stepped observer alongside the CSS animation purely to maintain aria-valuenow — the visual stays compositor-driven and the semantics update at sections.
Verification Steps
- Reach the end of the article and confirm the bar is full before the footer.
- Check
CSS.supports('animation-timeline: view()')in each target browser and confirm the right path runs. - Record a trace while scrolling in a supporting browser; there should be no script activity for the bar.
- In the fallback, confirm callbacks only at section boundaries.
- Test with a screen reader to confirm the bar is either hidden or reports sensible values.
Common Mistakes to Avoid
- Measuring the whole page. Footers and comments distort progress through the article.
- A dense threshold array as the fallback. It becomes a scroll listener in disguise.
- Animating
width. It triggers layout; usetransform: scaleX(). - Updating
aria-valuenowevery frame. Update it at meaningful steps only.
FAQ
What does animation-range: contain mean for a tall article?
For an element taller than the scrollport, the contain range runs from when the element's top reaches the top of the scrollport to when its bottom reaches the bottom — exactly the stretch in which the reader is progressing through it.
Why use timeline-scope?
The progress bar is not a descendant of the article, so by default it cannot see the article's view timeline. timeline-scope on a common ancestor makes the named timeline visible to the bar.
Is a stepped fallback acceptable?
For most readers, yes: progress per section is meaningful and the steps are small on long articles. A short transition smooths the jumps visually.
Can the observer version be made smooth?
With many thresholds, but each crossing is a main-thread task, which approaches the cost of a scroll listener. For smooth progress, prefer the scroll-driven animation and accept steps as the fallback.
Does the bar need to be fixed at the top?
No. It can sit in a sticky header or at the bottom of the screen. With a named timeline and timeline-scope, its position in the DOM does not matter.
What about articles shorter than the viewport?
The contain range degenerates: the article never covers the scrollport, so progress jumps from 0 to 1. Hide the bar for short articles, or define progress with the entry and exit ranges instead.
Related
- Scroll Spy Navigation with IntersectionObserver — the section-based sibling
- Measuring Scroll Depth Without Scroll Listeners — depth for analytics
- Making Scroll Spy Navigation Screen-Reader Friendly — accessible scroll indicators
↑ Back to Scroll-Driven Effects & Sticky Headers