BEGINNERREACTUI/UX

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.

Published Dec 1, 2025Updated Apr 7, 2026
reacthooksscrollanimationuinextjs

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 - innerHeight can be zero. On a page shorter than the viewport there is nothing to scroll, so the hook guards against divide-by-zero and simply leaves progress at 0.
  • It is page-level only. If you need to track scroll inside an overflowing <div>, swap window for a ref'd element and document.documentElement.scrollHeight for that element's scrollHeight.
  • SSR safe by design. The effect only runs in the browser, so window is 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 useEffect removes the listener on unmount, so you will not leak handlers when the component disappears.
X (Twitter)LinkedIn