Skip to main content

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 is documentHeight - 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, and clientHeight on 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.

Further Reading​