Skip to main content

useAnimationFrame

Call a function on every animation frame, for as long as the component is mounted.

Source
Loading...
function App() {
  const [running, setRunning] = useState(true)
  const [elapsed, setElapsed] = useState(0)
  const [fps, setFps] = useState(0)

  useAnimationFrame(
    (_timestamp, deltaTime) => {
      // `deltaTime` is 0 on the first frame after a start, so a pause is not
      // counted as one long frame.
      setElapsed((elapsed) => elapsed + deltaTime)
      if (deltaTime > 0) setFps(Math.round(1000 / deltaTime))
    },
    { disabled: !running },
  )

  return (
    <div style={{ userSelect: 'none' }}>
      <div
        style={{
          height: 8,
          marginBottom: '0.5rem',
          borderRadius: 9999,
          background: 'rgba(127, 127, 127, 0.3)',
          overflow: 'hidden',
        }}
      >
        <div
          style={{
            width: `${((elapsed % 2000) / 2000) * 100}%`,
            height: '100%',
            background: 'currentColor',
          }}
        />
      </div>
      <div>elapsed: {(elapsed / 1000).toFixed(2)} s</div>
      <div>fps: {running ? fps : '-'}</div>
      <button type="button" onClick={() => setRunning((r) => !r)}>
        {running ? 'Pause' : 'Resume'}
      </button>
    </div>
  )
}

Import​

import { useAnimationFrame } from '@tremolo-ui/react'

The callback​

It receives the frame's timestamp and the milliseconds since the previous frame:

useAnimationFrame((timestamp, deltaTime) => {
position.current += speed * deltaTime
})

deltaTime is 0 on the first frame of a loop. Moving by deltaTime rather than by a fixed amount per frame keeps the speed the same on a 60 Hz and a 120 Hz display.

The callback is read on every frame, so it can be written inline and sees the latest props and state. Rendering does not restart the loop.

Pausing​

disabled stops the loop:

useAnimationFrame(update, { disabled: !playing })

Turning it back on starts a new loop, whose first frame again has a deltaTime of 0. The time spent stopped is not reported as one long frame, so nothing jumps ahead on resume.

Drawing on a canvas​

To draw on a <canvas>, use AnimationCanvas instead. It runs the same loop and also keeps the canvas sized to the element and to the device pixel ratio. Reading a value for the rest of the page can happen in its draw too, so that there is one loop rather than two.

Svelte and Vue​

This hook is React only. Both frameworks already have a way to do the same, so @tremolo-ui/svelte and @tremolo-ui/vue do not repeat it.

Run the loop in an $effect and cancel it in the cleanup:

$effect(() => {
if (paused) return

let previous: number | undefined
let frame = requestAnimationFrame(function loop(timestamp) {
frame = requestAnimationFrame(loop)
update(timestamp, previous === undefined ? 0 : timestamp - previous)
previous = timestamp
})
return () => cancelAnimationFrame(frame)
})

Only what the effect reads before the first frame — paused here — restarts the loop. What update reads runs inside the frame callback, which the effect does not track, so it can read state freely.

Options​

The callback is the first argument. These are the second.

PropTypeDefault
disabledbooleanfalse

Full reference: UseAnimationFrameOptions