The definitive guide to requestAnimationFrame()
By Flavio Copes
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: