Hooks

useRaf

A requestAnimationFrame loop hook that hands every frame a delta/elapsed payload, with start/stop controls, an fps cap and automatic hidden-tab suspension.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface RafFrame {
  /**
   * Milliseconds since the **previous delivered frame**, and `0` on the first
   * frame of a run (mount, `start()` after a `stop()`, `reset()`, and the
   * return from a hidden tab when `pauseOnHidden` is on). There is no earlier
   * frame to measure against at those moments, and reporting the real gap
   * instead is exactly how a physics integration teleports a sprite off-screen
   * after a tab switch. Multiply movement by this, never by a constant.
   */
  delta: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-raf.json

Prompt

Build a React + TypeScript "useRaf" hook (no dependencies beyond React; uses
requestAnimationFrame / cancelAnimationFrame and document.visibilityState).

Contract
- `useRaf(callback: (frame: RafFrame) => void, options?: UseRafOptions):
  UseRafControls`.
- `RafFrame = { delta; elapsed; time; frame; fps; stop }`:
  - `delta` — ms since the previous DELIVERED frame, and exactly `0` on the
    first frame of a run (mount, resume, reset, return from a hidden tab).
  - `elapsed` — the sum of every delta since mount or the last `reset()`:
    running time, not wall clock.
  - `time` — the DOMHighResTimeStamp rAF passed in, untouched (same clock as
    `performance.now()`, so consumers can diff it against their own marks).
  - `frame` — 1-based count of delivered frames since mount/reset.
  - `fps` — smoothed frames per second (`0` on the first frame).
  - `stop` — the same function the hook returns, placed on the payload so a
    loop can end itself without the callback referencing the `const` it is
    being passed to (a temporal dead zone, and a React-compiler lint error).
- `UseRafOptions = { autoStart?: boolean = true; maxFps?: number | null =
  null; pauseOnHidden?: boolean = true }`.
- `UseRafControls = { start(): void; stop(): void; reset(): void; isRunning:
  boolean }`. `start`/`stop`/`reset` keep one identity for the component's
  lifetime, so they are safe in dependency arrays and on memoized children.
- Export `RafFrame`, `UseRafOptions` and `UseRafControls`.

Behavior
- Latest-callback ref: write `callback` into a ref in a bare effect with no
  dependency array, and have the loop call `callbackRef.current(frame)`. The
  loop-owning effect must NOT depend on `callback` — consumers pass an inline
  arrow closing over changing state, so depending on it would cancel and
  recreate the loop on every render, resetting the cap's cadence anchor and
  losing a frame each time.
- Exactly one piece of state, `isRunning`. Per-frame values never go through
  `setState`; they are handed to the callback, which writes them to a
  `style.transform`, a canvas, a `textContent`, or a `setState` it throttles
  itself. A hook that set state 60 times a second would re-render the
  consumer's subtree 60 times a second — the cost this hook exists to avoid.
- The loop-owning effect depends on `[isRunning, pauseOnHidden]` only. On
  entry it clears the timing baseline and calls `requestAnimationFrame(tick)`;
  its cleanup calls `cancelAnimationFrame` and removes the visibility
  listener. Nothing is scheduled while `isRunning` is false.
- `tick(time)` queues the NEXT frame before doing anything else, so a callback
  that throws does not permanently kill the loop and every cancellation goes
  through the single `frameRef` that now holds the queued id.
- Delta/elapsed maths, per delivered frame:
    delta   = lastTime === null ? 0 : time - lastTime
    lastTime = time
    elapsed += delta
    frame   += 1
  `lastTime = null` is the re-baseline sentinel, set on start, resume, reset
  and on becoming visible again. That single rule is what makes every kind of
  pause spike-free: the frame that re-establishes the baseline moves nothing.
- `fps` is an exponential moving average of the deltas, not `1000 / delta` of
  a single frame (which jitters ±10 fps and is unreadable):
    smoothed = smoothed === null ? delta : smoothed + (delta - smoothed) * 0.1
    fps      = smoothed > 0 ? 1000 / smoothed : 0
- fps cap. Resolve `maxFps` into an interval ONCE per render and pass it to
  the loop through a ref (same reasoning as the callback: a live fps slider
  must not rebuild the loop on every drag step, and `elapsed`/`frame` must
  keep counting across a cap change):
    cap      = Number.isFinite(maxFps) ? Math.max(1, maxFps) : null
    interval = cap === null ? 0 : 1000 / cap
  With `interval > 0`, gate each tick on `time - anchor >= interval - 1ms`
  (1 ms of slack, or a 60 fps cap on a 60 Hz display misses every second
  frame and silently renders at 30). After passing the gate, advance the
  anchor by WHOLE intervals rather than snapping it to `time`:
    anchor = time - ((time - anchor) % interval)
  Snapping makes every capped frame wait for the first display frame at or
  after a full interval, so a 24 fps cap on a 60 Hz screen quietly becomes 20;
  keeping the phase lets deliveries alternate 50/33 ms and average out to the
  rate that was asked for. Skipped frames are not lost time — their
  milliseconds arrive inside the next `delta`, so `elapsed` still tracks real
  seconds at 4 fps.
- `stop()` writes a `runningRef` synchronously AND sets `isRunning` false. The
  tick reads that ref before delivering, so `stop()` called from inside the
  frame callback ("stop at 100%") delivers no further frame, instead of
  drifting on for the render or two React needs to commit the state change;
  the effect cleanup then cancels the queued frame. `start()` is the mirror
  image and is idempotent — it resumes, keeping `elapsed` and `frame`.
- `reset()` zeroes `elapsed` and `frame`, drops the fps average, and clears
  the baseline. It does not start or stop the loop.
- `pauseOnHidden` (default true): subscribe to `visibilitychange`; cancel the
  frame when `document.hidden`, and restart with a cleared baseline when the
  tab returns — guarded on "no frame currently queued", because some browsers
  fire the event twice for one switch and a second loop would double every
  delta. `isRunning` is NOT touched: a hidden tab is not the user pausing.
  Browsers already stop firing rAF in a background tab, so saving work is not
  the point — without the re-baseline, the frame you come back on reports the
  entire background stay as one delta and `elapsed` absorbs it. Mounting into
  an already-hidden tab must not start the loop at all, for the same reason.
- Degenerate inputs: `maxFps` of `0` or a negative number clamps to 1 fps (a
  cap of zero frames is a dead loop, not a legal request); `null`, `Infinity`
  and `NaN` all read as uncapped; two `useRaf` calls in one component own two
  independent loops and counters; under StrictMode's double mount the cleanup
  cancels the first loop while the refs survive, so counters continue rather
  than double-count.
- Cleanup is total: on unmount, on `stop()`, and on a `pauseOnHidden` change,
  the queued frame is cancelled and the visibility listener removed. The hook
  touches `requestAnimationFrame`, `document` and the clock only inside the
  effect, so it is safe in a server-rendered tree and the first client render
  agrees with the server (`isRunning === autoStart`).

Rendering & styling
- The hook renders nothing and owns no DOM node; consumers own all output.
  Write per-frame values imperatively (`el.style.transform`, canvas ops,
  `el.textContent`) — that is the whole point of the payload.
- Controls: a play/pause toggle should be a real `button` with `aria-pressed`,
  activated by the native Space/Enter keys — never a div, and never natively
  `disabled` while it may hold focus (the browser drops focus to `body`); use
  `aria-disabled` plus a handler guard instead.
- Numeric readouts want `tabular-nums` so digits don't shift width every
  frame, and must NOT sit inside an `aria-live` region — a value that changes
  60 times a second would flood a screen reader. Announce the end of a run
  once, with a `role="status"` line.
- prefers-reduced-motion: don't run decorative motion. Either leave the loop
  stopped, or keep it running and stop applying transforms — readouts,
  progress and any state it drives must stay live and correct with motion off.
- Any visual built on top uses semantic tokens only: `bg-primary` for the
  moving mark, `bg-muted` for its track, `text-muted-foreground` for labels,
  `border`/`ring` for framing and focus.

Customization levers
- Spike clamping: if a consumer integrates physics, clamp inside the callback
  (`const dt = Math.min(frame.delta, 50)`) or add a `maxDelta` option — kept
  out of the default contract because a clamp below the cap's interval would
  quietly break every low `maxFps`.
- Fixed timestep: for deterministic simulation, accumulate `frame.delta` and
  run N fixed 16.67 ms steps per frame, using the remainder to interpolate.
- One shared loop: for dozens of consumers, hoist a module-level singleton
  loop plus a subscriber set (the shape `use-page-visibility` uses) so the
  page owns one rAF instead of N.
- Different suspend trigger: swap `visibilitychange` for window blur/focus
  ("pause whenever the window isn't focused"), or for an IntersectionObserver
  so a canvas only animates while it is on screen.
- Extra payload: `elapsed` is deliberately running time — expose a second
  wall-clock field (`time` minus the first frame's `time`) if a consumer needs
  to show how much was skipped, which is what the demo's hidden-tab cards do.

Concepts

  • Latest-callback ref — the frame callback is synced into a ref every render and the loop is built once per run, so an inline arrow closing over changing state is free: the newest closure runs on the next frame without the loop ever being cancelled, rebuilt, or having its cadence anchor reset.
  • Delta time as the only unit of motion — position is derived from delta/elapsed rather than incremented by a constant per tick, which is what makes the same code run at the same speed on a 60 Hz laptop and a 120 Hz phone, and what makes a dropped frame cost smoothness instead of accuracy.
  • Re-baselining instead of measuring the gap — every kind of pause (stop, reset, a hidden tab) sets one sentinel that makes the next frame report delta: 0. Without it, the frame after a pause carries the whole pause as elapsed time and any x += v * delta teleports across the screen.
  • Cap with drift correction — throttling asks two separate questions: "may this frame be delivered?" (compare against the ideal anchor, with a millisecond of slack) and "where is the next slot?" (advance the anchor by whole intervals, never snap it to now). Snapping is the reason naive 24 fps throttles run at 20 on a 60 Hz display.
  • Hidden-tab suspension is about the baseline, not the CPU — browsers already stop calling rAF in a background tab; what the visibility subscription buys is that returning re-baselines the clock and keeps background time out of elapsed, so isRunning can stay true and the animation resumes exactly where the user left it.
  • Zero per-frame state — nothing but isRunning lives in React state; frame values travel to the callback and are written straight to the DOM. This is the difference between a hook that animates and a hook that re-renders a component tree sixty times a second.

On This Page