The definitive guide to requestAnimationFrame()

By

Master requestAnimationFrame() with timestamps, delta time, cancellation, high-refresh displays, easing, frame budgets, and reduced-motion support.

~~~

requestAnimationFrame() schedules JavaScript to run before the browser’s next repaint.

It is the right tool for animations and visual updates that must stay synchronized with rendering.

The basic API is small. The tricky parts are time, cancellation, high-refresh screens, hidden tabs, and avoiding expensive layout work inside every frame.

Your first animation frame

Pass a callback to requestAnimationFrame():

requestAnimationFrame(() => {
  console.log('The browser is ready for a visual update')
})

The browser calls it before a repaint.

This schedules one callback. It does not create a loop by itself.

Create an animation loop

Schedule the next frame from inside the callback:

function animate() {
  // update the visual state
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

Each callback requests the next one.

The browser usually matches the display’s refresh rate when the page is visible.

Move an element

Here is a complete example:

const box = document.querySelector('.box')
let x = 0

function animate() {
  x += 2
  box.style.transform = `translateX(${x}px)`

  if (x < 300) {
    requestAnimationFrame(animate)
  }
}

requestAnimationFrame(animate)

The loop stops requesting frames when x reaches 300.

Using transform is usually smoother than changing left, because transforms can avoid relaying out the document.

Why not use setInterval()?

You can try to target 60 frames per second with a timer:

setInterval(animate, 1000 / 60)

But the timer does not know when the browser will paint.

It can fire too early, too late, or several times between visible frames. Work done between paints does not create extra visible frames.

requestAnimationFrame() lets the browser coordinate your update with its rendering pipeline.

Use timers for elapsed-time tasks. Use animation frames for visual work.

The callback receives a timestamp

The browser passes a high-resolution timestamp to every callback:

function animate(timestamp) {
  console.log(timestamp)
  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

The value is measured in milliseconds from the document’s time origin. It is related to performance.now(), not Date.now().

Use this timestamp to calculate progress.

Never assume a 60 Hz display

This animation adds 2 pixels per frame:

x += 2

It moves twice as fast on a 120 Hz display as on a 60 Hz display.

Animation speed should depend on elapsed time, not the number of frames.

Use delta time

Calculate the time since the previous frame:

const box = document.querySelector('.box')
const speed = 120

let x = 0
let previousTime

function animate(timestamp) {
  if (previousTime === undefined) {
    previousTime = timestamp
  }

  const delta = (timestamp - previousTime) / 1000
  previousTime = timestamp

  x += speed * delta
  box.style.transform = `translateX(${x}px)`

  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

speed is 120 pixels per second. delta is the elapsed fraction of a second.

The motion now has the same speed across refresh rates.

Animate over a fixed duration

For a 600 millisecond animation, calculate progress from the starting timestamp:

const box = document.querySelector('.box')
const duration = 600
let start

function animate(timestamp) {
  if (start === undefined) {
    start = timestamp
  }

  const elapsed = timestamp - start
  const progress = Math.min(elapsed / duration, 1)

  box.style.transform = `translateX(${progress * 300}px)`

  if (progress < 1) {
    requestAnimationFrame(animate)
  }
}

requestAnimationFrame(animate)

progress moves from 0 to 1.

Math.min() stops it from exceeding 1 on the final frame.

Add easing

Linear motion often feels mechanical.

An easing function transforms the progress value:

function easeOutCubic(value) {
  return 1 - Math.pow(1 - value, 3)
}

Use the eased value when calculating the position:

const easedProgress = easeOutCubic(progress)

box.style.transform =
  `translateX(${easedProgress * 300}px)`

Keep the time calculation linear. Apply easing only when converting progress into the visual value.

Cancel a scheduled frame

requestAnimationFrame() returns an ID:

const frameId = requestAnimationFrame(animate)

Pass it to cancelAnimationFrame():

cancelAnimationFrame(frameId)

In a loop, store the latest ID:

let frameId

function animate(timestamp) {
  // update
  frameId = requestAnimationFrame(animate)
}

frameId = requestAnimationFrame(animate)

function stop() {
  cancelAnimationFrame(frameId)
}

Canceling prevents the next scheduled callback. It does not undo changes made by previous frames.

Build start and stop controls

Protect the loop from being started twice:

let frameId
let running = false

function animate(timestamp) {
  // update
  frameId = requestAnimationFrame(animate)
}

function start() {
  if (running) return

  running = true
  frameId = requestAnimationFrame(animate)
}

function stop() {
  running = false
  cancelAnimationFrame(frameId)
}

Two active loops would update the same state twice per frame.

When restarting a delta-time animation, reset its previous timestamp too.

Handle hidden tabs

Browsers pause or heavily throttle animation frames in background tabs.

That saves CPU and battery. It also means the next timestamp can be far ahead when the tab becomes visible again.

For a decorative animation, reset the previous time when visibility changes:

document.addEventListener('visibilitychange', () => {
  if (!document.hidden) {
    previousTime = undefined
  }
})

For a simulation, decide what hidden time means. You might advance the simulation by real elapsed time, pause it, or cap the largest delta.

Cap very large delta values

A suspended laptop or debugger breakpoint can produce a huge delta.

Cap it when a large jump would break the animation:

const rawDelta = (timestamp - previousTime) / 1000
const delta = Math.min(rawDelta, 0.1)

This limits one update to 100 milliseconds.

Do not cap time for a countdown or clock that must reflect reality. Recalculate those values from an absolute timestamp instead.

Understand the frame budget

At 60 Hz, one frame lasts about 16.7 milliseconds.

At 120 Hz, it lasts about 8.3 milliseconds.

Your callback shares that time with style calculation, layout, paint, compositing, other JavaScript, and browser work.

If the main thread misses the deadline, the display repeats the previous frame. The user sees a stutter.

Keep per-frame work small and predictable.

Avoid layout thrashing

Reading layout after changing styles can force the browser to calculate layout immediately.

This pattern can become expensive in a loop:

box.style.width = `${width}px`
const height = box.offsetHeight

Group reads before writes:

const height = box.offsetHeight

requestAnimationFrame(() => {
  box.style.width = `${height}px`
})

For several elements, collect all measurements first. Then apply all visual changes.

DevTools Performance recordings can reveal repeated forced layouts.

Prefer transform and opacity

Animations of transform and opacity often avoid layout and can be composited efficiently:

box.style.transform = `translateX(${x}px)`
box.style.opacity = opacity

Animating width, height, top, or left can require layout and paint.

This is a guideline, not a guarantee. Measure the actual page. Large layers and heavy effects can still be expensive.

Batch DOM writes into one frame

Several events can fire before the next repaint. Schedule one visual update instead of changing the DOM every time:

let scheduled = false
let latestX = 0

window.addEventListener('pointermove', event => {
  latestX = event.clientX

  if (scheduled) return

  scheduled = true

  requestAnimationFrame(() => {
    cursor.style.transform = `translateX(${latestX}px)`
    scheduled = false
  })
})

This keeps only the latest pointer position and performs one update per frame.

Use requestAnimationFrame() for canvas

Canvas animation follows the same loop:

const canvas = document.querySelector('canvas')
const context = canvas.getContext('2d')

function animate(timestamp) {
  context.clearRect(0, 0, canvas.width, canvas.height)

  // draw the current frame

  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

Canvas drawing still runs on the main thread unless you move supported work to an OffscreenCanvas worker.

The browser coordinating the callback does not make expensive drawing free.

Respect reduced-motion preferences

Some users ask the operating system to reduce motion.

Read that preference with matchMedia():

const reducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
)

Skip or simplify non-essential animation:

if (reducedMotion.matches) {
  box.style.transform = 'translateX(300px)'
} else {
  requestAnimationFrame(animate)
}

Do not remove essential state changes. Show the final state without the motion.

Listen for changes if the page stays open for a long time:

reducedMotion.addEventListener('change', event => {
  if (event.matches) {
    stop()
  }
})

Clean up when a component disappears

Long-running applications must cancel loops that no longer have a visible owner.

In a component cleanup function:

return () => {
  cancelAnimationFrame(frameId)
}

Also remove event listeners created by the animation.

An abandoned loop can keep closures and DOM references alive while doing useless work.

requestAnimationFrame() vs CSS animations

Use CSS transitions or animations when CSS can express the visual change:

.box {
  transition: transform 300ms ease-out;
}

.box.is-open {
  transform: translateX(300px);
}

CSS is simpler for state-to-state animation and keyframes.

Use requestAnimationFrame() when each frame depends on JavaScript state, physics, canvas drawing, pointer input, or custom timing.

Read my CSS animations guide for the declarative option.

requestAnimationFrame() vs Web Animations

The Web Animations API can run keyframe animations from JavaScript:

box.animate(
  [
    { transform: 'translateX(0)' },
    { transform: 'translateX(300px)' },
  ],
  {
    duration: 600,
    easing: 'ease-out',
  }
)

Use it when you want JavaScript control over a keyframe-style animation.

Use an animation-frame loop for an open-ended simulation or per-frame calculation.

Common mistakes

Moving by a fixed amount per frame

The speed changes with the refresh rate. Use the callback timestamp.

Starting the loop more than once

Track whether it is already running.

Forgetting to cancel it

Cancel the latest request when the animation or component ends.

Using Date.now() for frame progress

Use the timestamp passed to the callback. It is monotonic and shared by callbacks in the same frame.

Doing heavy work every frame

Move unrelated work out of the loop, cache stable values, and profile the result.

Assuming every screen is 60 Hz

Modern displays commonly use other refresh rates. Make motion time-based.

Ignoring background-tab pauses

Reset, cap, or deliberately account for the large elapsed time.

A reusable duration helper

This helper animates a progress value from 0 to 1:

function animateFor(duration, update) {
  let frameId
  let start

  function frame(timestamp) {
    if (start === undefined) {
      start = timestamp
    }

    const progress = Math.min(
      (timestamp - start) / duration,
      1
    )

    update(progress)

    if (progress < 1) {
      frameId = requestAnimationFrame(frame)
    }
  }

  frameId = requestAnimationFrame(frame)

  return () => cancelAnimationFrame(frameId)
}

Use it like this:

const cancel = animateFor(600, progress => {
  box.style.transform = `translateX(${progress * 300}px)`
})

Call cancel() if the animation should stop early.

The main lesson is simple: let the browser choose the frame, and use elapsed time to choose the state.

~~~

Related posts about platform: