javascript 94 lines · 3 tabs

Throttle a Scroll-Driven Reading Progress Bar with a useScrollProgress Hook

Shared by codesnips Sep 2026
3 tabs
import { useCallback, useEffect, useRef } from "react";

export function useRafThrottle(callback) {
  const callbackRef = useRef(callback);
  const frameRef = useRef(null);
  const latestArgs = useRef([]);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  const throttled = useCallback((...args) => {
    latestArgs.current = args;
    if (frameRef.current !== null) return;
    frameRef.current = requestAnimationFrame(() => {
      frameRef.current = null;
      callbackRef.current(...latestArgs.current);
    });
  }, []);

  useEffect(() => {
    return () => {
      if (frameRef.current !== null) {
        cancelAnimationFrame(frameRef.current);
      }
    };
  }, []);

  return throttled;
}
3 files · javascript Explain with highlit

A reading progress bar needs to react to every scroll event, but the browser fires scroll far faster than the screen refreshes, so naive handlers thrash layout and drop frames. This snippet splits the problem across three collaborating files: a low-level rAF throttle hook, a useScrollProgress hook that computes how far the document has been read, and a thin presentational component that renders the bar.

In useRafThrottle hook, the throttling is done with requestAnimationFrame rather than a setTimeout interval. The returned function schedules at most one callback per frame: repeated calls before the frame fires just overwrite latestArgs.current, so work coalesces to the display's refresh rate. Reading the DOM inside a rAF callback also aligns measurement with the browser's own paint cycle, which avoids forced synchronous layout. The hook keeps the caller's latest callback in a ref so the throttled function has a stable identity while still invoking fresh logic, and it cancels any pending frame on unmount to prevent calls into an unmounted component.

In useScrollProgress hook, computeProgress derives a 0..1 value from scrollTop, scrollHeight, and the viewport height. The denominator is the scrollable distance, guarded against division by zero for short pages by returning 0 when there is nothing to scroll. The measurement is wrapped in useRafThrottle and bound to both scroll and resize, because a resize changes scrollHeight and must recompute progress. Listeners use { passive: true } so the browser knows the handler never calls preventDefault, letting it scroll on the compositor thread. An initial call to update() seeds progress for pages loaded already scrolled (for example when navigating to an anchor).

In ReadingProgressBar, the numeric progress becomes a scaleX transform, which is cheaper than animating width since transforms skip layout. aria-hidden marks the bar decorative, and clamping keeps the value in range.

The main trade-off is that rAF throttling ties update frequency to frame rate rather than a fixed interval, which is usually exactly what visual UI wants. A pitfall to watch is measuring document.documentElement versus a scrolling container — this implementation assumes the document scrolls, and a container-scroll variant would pass the element into the hook instead.


Related snips

Share this code

Here's the card — post it anywhere.

Throttle a Scroll-Driven Reading Progress Bar with a useScrollProgress Hook — share card
Link copied