Implementing Article Reading Progress Bar on Website
A reading progress bar is a thin line at the top of the page that shows how much of an article the user has read. Commonly used on blogs and media websites. Important detail: calculate progress based on the article height, not the entire page height.
Basic Implementation
<div class="reading-progress" role="progressbar" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="0" aria-label="Reading progress">
<div class="reading-progress__bar"></div>
</div>
.reading-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
z-index: 9999;
background: transparent;
}
.reading-progress__bar {
height: 100%;
width: 0%;
background: linear-gradient(to right, #6366f1, #8b5cf6);
transition: width 0.1s linear;
transform-origin: left;
}
/* Below sticky header */
.site-header ~ .reading-progress,
.site-header + * .reading-progress {
top: var(--header-height, 64px);
}
function initReadingProgress(articleSelector: string = 'article, main, .post-content') {
const bar = document.querySelector('.reading-progress__bar') as HTMLElement
const progressEl = document.querySelector('.reading-progress') as HTMLElement
const article = document.querySelector(articleSelector) as HTMLElement
if (!bar || !article) return
function update() {
const articleRect = article.getBoundingClientRect()
const articleTop = articleRect.top + window.scrollY
const articleBottom = articleTop + article.offsetHeight
// Progress relative to article, not entire page
const viewportBottom = window.scrollY + window.innerHeight
const readAmount = viewportBottom - articleTop
const totalToRead = article.offsetHeight
const progress = Math.min(100, Math.max(0, (readAmount / totalToRead) * 100))
bar.style.width = `${progress}%`
progressEl.setAttribute('aria-valuenow', String(Math.round(progress)))
}
let ticking = false
window.addEventListener('scroll', () => {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
update()
ticking = false
})
}, { passive: true })
update()
}
React Hook and Component
import { useEffect, useState, useRef } from 'react'
function useReadingProgress(contentRef: React.RefObject<HTMLElement>) {
const [progress, setProgress] = useState(0)
useEffect(() => {
let ticking = false
function calculate() {
const el = contentRef.current
if (!el) return
const { top, height } = el.getBoundingClientRect()
const absoluteTop = top + window.scrollY
const readAmount = window.scrollY + window.innerHeight - absoluteTop
const pct = Math.min(100, Math.max(0, (readAmount / height) * 100))
setProgress(pct)
}
const handler = () => {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
calculate()
ticking = false
})
}
window.addEventListener('scroll', handler, { passive: true })
window.addEventListener('resize', handler, { passive: true })
calculate()
return () => {
window.removeEventListener('scroll', handler)
window.removeEventListener('resize', handler)
}
}, [contentRef])
return progress
}
export function ReadingProgressBar({ contentRef }: { contentRef: React.RefObject<HTMLElement> }) {
const progress = useReadingProgress(contentRef)
return (
<div
className="reading-progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress)}
aria-label="Article reading progress"
>
<div
className="reading-progress__bar"
style={{ width: `${progress}%` }}
/>
</div>
)
}
// Usage:
function ArticlePage({ post }: { post: Post }) {
const contentRef = useRef<HTMLDivElement>(null)
return (
<>
<ReadingProgressBar contentRef={contentRef} />
<article>
<h1>{post.title}</h1>
<div ref={contentRef} className="post-content">
{post.content}
</div>
</article>
</>
)
}
Design Variations
/* Option 1: Vertical bar on right side */
.reading-progress--vertical {
position: fixed;
top: 0;
right: 0;
width: 3px;
height: 100%;
bottom: auto;
left: auto;
}
.reading-progress--vertical .reading-progress__bar {
width: 100%;
height: 0%;
transition: height 0.1s linear;
}
/* Option 2: In header itself */
.site-header::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background: #6366f1;
width: var(--reading-progress, 0%);
transition: width 0.1s linear;
}
// CSS custom property instead of direct element manipulation
window.addEventListener('scroll', () => {
const progress = calculateProgress()
document.documentElement.style.setProperty('--reading-progress', `${progress}%`)
}, { passive: true })
Analytics: Reading Time and Scroll Depth
const MILESTONES = [25, 50, 75, 90, 100]
const reported = new Set<number>()
window.addEventListener('scroll', () => {
const progress = Math.round(calculateProgress())
MILESTONES.forEach(milestone => {
if (progress >= milestone && !reported.has(milestone)) {
reported.add(milestone)
// Google Analytics 4
gtag('event', 'scroll_depth', {
event_category: 'reading',
value: milestone,
page_title: document.title,
})
// Yandex.Metrika
ym(COUNTER_ID, 'reachGoal', `scroll_${milestone}`, { percent: milestone })
}
})
}, { passive: true })
Timeframe
Progress bar — 2–3 hours including layout and template integration. With analytics and design variations — half a day.







