useScrollProgress Hook for React
A tiny, SSR-safe React hook that tracks vertical scroll progress as a 0 to 1 value. Perfect for reading progress bars and scroll-linked effects.
useScrollProgress is a tiny React hook that tracks the vertical scroll position of the page and returns a normalized value between 0 and 1 (or 0% to 100%). Use it for reading progress bars on long articles, scroll-linked hero animations, parallax effects, and "how far have I read" indicators in blogs and documentation sites.
Tested on React 19, TypeScript 5.6, Next.js 16.
When to Use This
- Reading progress bars on blog posts, docs, and long-form articles.
- Scroll-linked hero animations: scale, fade, or rotate an element as the user scrolls down the first viewport.
- "Back to top" button visibility: show the button once progress crosses 10% or so.
- Sticky section highlighting: pair a few breakpoints with the progress value to highlight the current section in a sidebar.
Don't use this when you need per-element visibility (fade in a card as it enters the viewport, lazy-load an image as it approaches the fold). Reach for useIntersectionObserver instead, because it is much cheaper than doing per-element math on every scroll tick.
Code
import { useEffect, useState } from 'react';
export function useScrollProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const updateProgress = () => {
const currentScroll = window.scrollY;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
if (scrollHeight) {
setProgress(Number((currentScroll / scrollHeight).toFixed(2)));
}
};
window.addEventListener('scroll', updateProgress, { passive: true });
updateProgress(); // Initial check
return () => window.removeEventListener('scroll', updateProgress);
}, []);
return progress;
}The toFixed(2) call is not cosmetic: it keeps progress stable at two decimals so React only re-renders on meaningful changes, not on every sub-pixel scroll tick. The passive listener hint tells the browser the handler will never call preventDefault, so scroll stays smooth on mobile.
Usage
Here is a reading progress bar that sticks to the top of the screen:
import { useScrollProgress } from '@/hooks/useScrollProgress';
export function ReadingProgressBar() {
const progress = useScrollProgress();
return (
<div className="fixed top-0 left-0 h-1 w-full bg-transparent z-50">
<div
className="h-full bg-cyan-500 transition-all duration-150 ease-out"
style={{ width: `${progress * 100}%` }}
/>
</div>
);
}The same progress value can drive opacity, transform, or any CSS custom property for parallax effects.
Things to Know
- It rounds to two decimals. That gives 100 distinct values, which is more than enough for a smooth bar and cheap enough to ignore in terms of re-renders. Remove the rounding only if you genuinely need sub-percent precision.
scrollHeight - innerHeightcan be zero. On a page shorter than the viewport there is nothing to scroll, so the hook guards against divide-by-zero and simply leavesprogressat0.- It is page-level only. If you need to track scroll inside an overflowing
<div>, swapwindowfor a ref'd element anddocument.documentElement.scrollHeightfor that element'sscrollHeight. - SSR safe by design. The effect only runs in the browser, so
windowis never touched during server rendering. Safe inside Next.js App Router Server Components that import it via a client component. - One listener per mount. The cleanup in
useEffectremoves the listener on unmount, so you will not leak handlers when the component disappears.
Related Snippets & Reading
- useIntersectionObserver Hook: the right tool for per-element visibility, lazy loading, and fade-in animations.
- useDebounce Hook: another tiny React utility I use in nearly every project.
- Building a Modern Docs Generator with Next.js 16: the blog where I first used this hook for a docs site reading indicator.