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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-raf.jsonPrompt
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/elapsedrather 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 anyx += v * deltateleports 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, soisRunningcan stay true and the animation resumes exactly where the user left it. - Zero per-frame state — nothing but
isRunninglives 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.
useMap
Map-shaped React state: set, setAll, remove, replace and reset over a real Map, with any key type, copy-on-write updates, no-op bail-outs and a never-changing actions object.
useScript
An on-demand external script loader hook that reports idle, loading, ready or error and keeps exactly one deduped tag per src across every caller.