React Scroll Progress Bar Tutorial
Scroll progress bars show readers how far through a page they've scrolled—a visual signal that's especially valuable for long-form content like blogs and documentation. In React, you track the scroll position relative to the total scrollable height, then update a progress bar's width. This requires listening to scroll events, calculating the progress percentage, and updating state efficiently without causing performance issues.
Calculating Scroll Progress
The scroll progress percentage is the ratio of pixels scrolled to total scrollable height. Here's the formula: (scrollTop / (documentHeight - windowHeight)) × 100. Track this with a scroll event listener:
import { useEffect, useState } from 'react';
export default function ScrollProgressBar() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight;
const winHeight = window.innerHeight;
const scrollableHeight = docHeight - winHeight;
const scrollProgress = scrollableHeight > 0
? (scrollTop / scrollableHeight) * 100
: 0;
setProgress(scrollProgress);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
height: '4px',
backgroundColor: '#e5e7eb',
width: '100%',
zIndex: 1000,
}}
>
<div
style={{
height: '100%',
backgroundColor: '#3b82f6',
width: `${progress}%`,
transition: 'width 0.1s ease-out',
}}
/>
</div>
);
}
The { passive: true } flag optimizes scroll performance by telling the browser that the listener won't call preventDefault(). This is safe for scroll handlers and reduces jank on mobile.
Optimizing Scroll Performance with Throttling
Scroll events fire many times per second, so updating state on every event causes unnecessary re-renders. Throttle the updates using a ref-based timeout:
import { useEffect, useState, useRef } from 'react';
export default function ThrottledScrollProgress() {
const [progress, setProgress] = useState(0);
const throttleRef = useRef(null);
useEffect(() => {
const handleScroll = () => {
if (throttleRef.current) return;
throttleRef.current = setTimeout(() => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight;
const winHeight = window.innerHeight;
const scrollableHeight = docHeight - winHeight;
const scrollProgress = scrollableHeight > 0
? (scrollTop / scrollableHeight) * 100
: 0;
setProgress(scrollProgress);
throttleRef.current = null;
}, 16); // ~60 FPS
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
clearTimeout(throttleRef.current);
};
}, []);
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
height: '3px',
backgroundColor: 'transparent',
width: '100%',
zIndex: 1000,
}}
>
<div
style={{
height: '100%',
background: 'linear-gradient(to right, #3b82f6, #8b5cf6)',
width: `${progress}%`,
transition: 'width 0.08s ease-out',
}}
/>
</div>
);
}
This throttled version limits state updates to once per 16ms (60 FPS), reducing re-renders and keeping scroll smooth even on slower devices.
Scroll Progress within a Container
For single-page app scenarios, you often need to track scroll progress within a specific container, not the window. Use a ref to the scrollable container and scrollTop/scrollHeight:
import { useEffect, useState, useRef } from 'react';
export default function ContainerScrollProgress() {
const [progress, setProgress] = useState(0);
const containerRef = useRef(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleScroll = () => {
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
const scrollableHeight = scrollHeight - clientHeight;
const scrollProgress = scrollableHeight > 0
? (scrollTop / scrollableHeight) * 100
: 0;
setProgress(scrollProgress);
};
container.addEventListener('scroll', handleScroll, { passive: true });
return () => container.removeEventListener('scroll', handleScroll);
}, []);
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '400px',
overflow: 'auto',
border: '1px solid #ccc',
borderRadius: '8px',
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
height: '3px',
backgroundColor: '#3b82f6',
width: `${progress}%`,
transition: 'width 0.08s',
}}
/>
<div style={{ padding: '16px' }}>
<p>Scroll within this container to see progress.</p>
{Array.from({ length: 50 }, (_, i) => (
<p key={i}>Paragraph {i + 1}</p>
))}
</div>
</div>
);
}
Use container.scrollTop, container.scrollHeight, and container.clientHeight to calculate progress within any scrollable element, not just the window.
Key Takeaways
- Calculate scroll progress as
(scrollTop / (totalScrollableHeight)) × 100, where scrollable height isdocumentHeight - windowHeight. - Use the
{ passive: true }flag on scroll listeners to improve performance and avoid jank. - Throttle scroll updates with a ref-based timeout to limit state updates to 60 FPS, reducing unnecessary re-renders.
- For container-based scrolling, use
scrollTop,scrollHeight, andclientHeighton the container element.
Frequently Asked Questions
Why is my progress bar jumpy or laggy?
You're likely updating state on every single scroll event. Use throttling (as shown above) or requestAnimationFrame to batch updates. Alternatively, use a CSS transition on the progress bar's width to smooth out discrete updates.
How do I make the progress bar disappear after scrolling stops?
Track a "visible" state that defaults to false. When scrolling, set it to true. Use a timeout that resets to false after 2 seconds of inactivity. Combine with CSS opacity transitions for a fade-out effect.
Can I use scroll progress for analytics?
Yes. Track milestones like 25%, 50%, 75%, and 100% scroll. Fire a tracking event (e.g., gtag('event', 'scroll_milestone', { milestone: 75 })) when the user crosses these thresholds. Store a ref to the last fired milestone to avoid duplicate events.
How do I handle dynamic content that changes the scroll height?
Use a ResizeObserver on the container to detect height changes, or recalculate on dynamic content updates. Alternatively, listen to both scroll and the load event (for images), or a custom event from your data-loading layer.
Should I use IntersectionObserver instead of scroll events?
IntersectionObserver is better for detecting when specific elements enter the viewport (lazy loading, tracking sections). For a continuous progress bar, scroll events are simpler and more direct. IntersectionObserver has higher overhead for continuous tracking.