javascript css 100 lines · 4 tabs

Throttling a Scroll-Driven Reading Progress Bar with requestAnimationFrame in React

Shared by codesnips Aug 2026
4 tabs
import { useCallback, useEffect, useRef } from 'react';

export function useRafThrottle(callback) {
  const savedCallback = useRef(callback);
  const frame = useRef(null);
  const latestArgs = useRef([]);

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

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

  return useCallback((...args) => {
    latestArgs.current = args;
    if (frame.current !== null) return; // a frame is already scheduled

    frame.current = requestAnimationFrame(() => {
      frame.current = null;
      savedCallback.current(...latestArgs.current);
    });
  }, []);
}
4 files · javascript, css Explain with highlit

Scroll events fire far more often than the screen can repaint, so recomputing layout and writing to the DOM on every event wastes work and causes jank. The pattern shown here coalesces every burst of scroll events into at most one update per animation frame, which is exactly the cadence at which the browser will actually paint. This is the canonical requestAnimationFrame throttle, applied to a reading-progress indicator.

The useRafThrottle hook wraps any callback so that repeated calls within a single frame collapse into one. It keeps two refs: frame holds the pending requestAnimationFrame id, and latestArgs holds the most recent arguments. When the throttled function is invoked and a frame is already scheduled, it simply updates latestArgs and returns, so intermediate calls are dropped rather than queued. Only when the frame fires does it invoke the wrapped callback with the freshest arguments and clear frame. Storing the callback in savedCallback lets the hook read the latest closure without re-subscribing, and the cleanup in useEffect cancels any in-flight frame on unmount to avoid a stray callback firing against a torn-down component.

The useScrollProgress hook builds on that primitive. It computes progress from scrollTop, scrollHeight, and clientHeight on documentElement, guarding against a zero-height document so the ratio never becomes NaN. The update function is wrapped by useRafThrottle, and both scroll and resize are subscribed with { passive: true } — passive listeners tell the browser it need not wait to see if the handler calls preventDefault, which keeps scrolling smooth. An initial update() sets the correct value before the first scroll.

The ReadingProgressBar component consumes the hook and writes the ratio to a CSS custom property via transform: scaleX(...), driving the bar entirely on the compositor. Because the width is expressed as a transform rather than a layout property, updates avoid reflow.

The main trade-off is that requestAnimationFrame throttling ties update frequency to paint frequency, dropping intermediate values — perfect for visual state, but unsuitable when every event must be observed. It also pauses in background tabs, which is usually desirable. This approach is the right reach for any high-frequency, visual-only signal such as scroll, pointermove, or resize.


Related snips

ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
ruby
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    Bullet.alert = false
    Bullet.bullet_logger = true
    Bullet.console = true

N+1 query detection with Bullet gem

rails performance activerecord
by Alex Kumar 2 tabs
typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 2 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs
ruby
json.array! @posts do |post|
  json.cache! ['v1', post], expires_in: 1.hour do
    json.id post.id
    json.title post.title
    json.excerpt post.excerpt
    json.published_at post.published_at

Fragment caching for expensive JSON serialization

rails caching performance
by Alex Kumar 1 tab
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab

Share this code

Here's the card — post it anywhere.

Throttling a Scroll-Driven Reading Progress Bar with requestAnimationFrame in React — share card
Link copied