docs/beyond/concepts/requestanimationframe.mdx
Why do some JavaScript animations feel buttery smooth while others are janky and choppy? Why does your animation freeze when you switch browser tabs? And how do game developers create animations that run at consistent speeds regardless of frame rate?
The answer is requestAnimationFrame — the browser API designed specifically for smooth, efficient animations.
// Smooth animation that syncs with the browser's refresh rate
function animate() {
// Update animation state
element.style.transform = `translateX(${position}px)`;
position += 2;
// Request next frame
if (position < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
Unlike setInterval, requestAnimationFrame synchronizes with your monitor's refresh rate, pauses when the tab is hidden, and lets the browser optimize rendering for maximum performance. MDN notes that this automatic pausing also saves CPU and battery life on mobile devices.
requestAnimationFrame (often abbreviated as "rAF") is a browser API that tells the browser you want to perform an animation. According to the WHATWG HTML specification, it requests a callback to be executed just before the browser performs its next repaint, typically at 60 frames per second (60fps) on most displays.
Here's the key insight: instead of guessing when to update your animation with arbitrary timing like setInterval(fn, 16), requestAnimationFrame lets the browser tell you when it's the optimal time to draw the next frame.
// The browser calls this function when it's ready to paint
function drawFrame(timestamp) {
// timestamp = milliseconds since page load
console.log(`Frame at ${timestamp}ms`);
// Do your animation work here
updatePosition();
// Request the next frame
requestAnimationFrame(drawFrame);
}
// Start the animation loop
requestAnimationFrame(drawFrame);
The timestamp parameter is a DOMHighResTimeStamp representing the time when the frame started rendering. You'll use this for calculating animation progress and delta time.
Think of how movies work. A film projector shows you 24 still images (frames) per second, and your brain perceives smooth motion. If frames come at irregular intervals, the motion looks jerky.
┌─────────────────────────────────────────────────────────────────────────┐
│ THE FILM PROJECTOR │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Frame 1 │ │ Frame 2 │ │ Frame 3 │ │ Frame 4 │ ... │
│ │ ⚫ │ │ ⚫ │ │ ⚫ │ │ ⚫ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ 16.67ms 16.67ms 16.67ms 16.67ms │
│ │
│ ════════════════════════════════════════════════════ │
│ SMOOTH MOTION (60fps) │
│ ════════════════════════════════════════════════════ │
│ │
│ setInterval: rAF tells the PROJECTOR when to advance │
│ YOU guess when requestAnimationFrame: │
│ to show frames PROJECTOR tells YOU when it's ready │
│ │
└─────────────────────────────────────────────────────────────────────────┘
With setInterval, you're trying to guess when the projector will be ready. Sometimes you're early (frame waits), sometimes you're late (frame skipped). With requestAnimationFrame, the projector signals when it's ready for the next frame.
You might think setInterval(fn, 1000/60) would give you 60fps. Here's why it doesn't work well for animations:
setInterval isn't precise. The browser might be busy, and your callback could run 20ms or 30ms apart instead of exactly 16.67ms.
// ❌ WRONG - setInterval for animations
let position = 0;
setInterval(() => {
position += 2;
element.style.left = position + 'px';
}, 1000 / 60); // Aims for ~16.67ms, often misses
setInterval keeps running even when the tab is hidden. Your animation keeps computing frames that nobody sees, draining battery and CPU.
The browser might repaint at different times than your interval fires. You could update the DOM twice between repaints (wasted work) or miss the repaint window entirely (dropped frame).
// ✓ CORRECT - requestAnimationFrame for animations
let position = 0;
function animate() {
position += 2;
element.style.left = position + 'px';
if (position < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
| Feature | setInterval | requestAnimationFrame |
|---|---|---|
| Synced with display | No | Yes (matches refresh rate) |
| Background tabs | Keeps running | Pauses automatically |
| Battery efficiency | Poor | Good |
| Frame timing | Can drift, miss frames | Browser-optimized |
| Animation smoothness | Can be janky | Consistently smooth |
Here's the fundamental pattern for requestAnimationFrame:
// Basic animation loop pattern
function animate() {
// 1. Update animation state
updateSomething();
// 2. Draw/render
render();
// 3. Request next frame (if animation should continue)
requestAnimationFrame(animate);
}
// Kick off the animation
requestAnimationFrame(animate);
const box = document.getElementById('box');
let position = 0;
function animate() {
// Update position
position += 2;
// Apply to DOM
box.style.transform = `translateX(${position}px)`;
// Continue until we reach 400px
if (position < 400) {
requestAnimationFrame(animate);
}
}
// Start
requestAnimationFrame(animate);
Every requestAnimationFrame callback receives a high-resolution timestamp. This is crucial for frame-rate independent animations.
function animate(timestamp) {
// timestamp = milliseconds since the page loaded
console.log(`Current time: ${timestamp}ms`);
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
// Output (example):
// Current time: 16.67ms
// Current time: 33.34ms
// Current time: 50.01ms
// ...
The timestamp is the same as what you'd get from performance.now() at the start of the callback, but using the provided timestamp is more accurate for animation timing.
Here's a critical concept: if you move an object 2 pixels per frame, it moves faster on a 144Hz monitor than a 60Hz monitor. The 144Hz display renders more frames per second, so you get more 2-pixel jumps.
The solution is delta time — the time elapsed since the last frame. Instead of moving by a fixed amount per frame, you move based on time elapsed.
const box = document.getElementById('box');
let position = 0;
let lastTime = 0;
const speed = 200; // pixels per SECOND (not per frame!)
function animate(currentTime) {
// Calculate time since last frame
const deltaTime = (currentTime - lastTime) / 1000; // Convert to seconds
lastTime = currentTime;
// Move based on time, not frames
// At 200px/sec, we move 200 * deltaTime pixels each frame
position += speed * deltaTime;
box.style.transform = `translateX(${position}px)`;
if (position < 500) {
requestAnimationFrame(animate);
}
}
// First frame needs special handling
requestAnimationFrame((timestamp) => {
lastTime = timestamp;
requestAnimationFrame(animate);
});
Now the box moves at 200 pixels per second regardless of whether the display runs at 30Hz, 60Hz, or 144Hz.
┌─────────────────────────────────────────────────────────────────────────┐
│ DELTA TIME VISUALIZATION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ WITHOUT DELTA TIME: │
│ ──────────────────── │
│ 60Hz Monitor: ▶────▶────▶────▶────▶ (60 jumps/sec) │
│ 144Hz Monitor: ▶─▶─▶─▶─▶─▶─▶─▶─▶─▶─ (144 jumps/sec) FASTER! │
│ │
│ WITH DELTA TIME: │
│ ──────────────── │
│ 60Hz Monitor: ▶────▶────▶────▶────▶ (200px/sec) │
│ 144Hz Monitor: ▶─▶─▶─▶─▶─▶─▶─▶─▶─▶─ (200px/sec) SAME SPEED! │
│ (smaller jumps, more frames, same total distance) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
requestAnimationFrame returns an ID that you can use with cancelAnimationFrame to stop the animation.
let animationId;
let position = 0;
function animate() {
position += 2;
element.style.transform = `translateX(${position}px)`;
// Store the ID so we can cancel later
animationId = requestAnimationFrame(animate);
}
// Start animation
function startAnimation() {
animationId = requestAnimationFrame(animate);
}
// Stop animation
function stopAnimation() {
cancelAnimationFrame(animationId);
}
// Usage
document.getElementById('start').onclick = startAnimation;
document.getElementById('stop').onclick = stopAnimation;
A common bug is starting multiple animation loops by clicking a button repeatedly:
// ❌ BUG: Clicking start multiple times creates multiple loops!
let animationId;
document.getElementById('start').onclick = () => {
function animate() {
// ...animation code...
animationId = requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
};
// ✓ FIX: Cancel any existing animation before starting
document.getElementById('start').onclick = () => {
cancelAnimationFrame(animationId); // Cancel previous animation
function animate() {
// ...animation code...
animationId = requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
};
For animations that should last a specific duration, track progress as a value from 0 to 1:
const duration = 2000; // 2 seconds
let startTime = null;
function animate(timestamp) {
if (!startTime) startTime = timestamp;
// Calculate progress (0 to 1)
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
// Use progress to determine position
// Linear: 0 → 0px, 0.5 → 200px, 1 → 400px
const position = progress * 400;
element.style.transform = `translateX(${position}px)`;
// Continue until complete
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
Linear animations feel robotic. Easing functions make motion feel natural:
// Easing functions take progress (0-1) and return eased progress (0-1)
const easing = {
// Starts slow, ends fast
easeIn: (t) => t * t,
// Starts fast, ends slow
easeOut: (t) => t * (2 - t),
// Slow at both ends
easeInOut: (t) => t < 0.5
? 2 * t * t
: -1 + (4 - 2 * t) * t,
// Bouncy effect
easeOutBounce: (t) => {
if (t < 1 / 2.75) {
return 7.5625 * t * t;
} else if (t < 2 / 2.75) {
return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
} else if (t < 2.5 / 2.75) {
return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
} else {
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
}
}
};
function animate(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const linearProgress = Math.min(elapsed / duration, 1);
// Apply easing
const easedProgress = easing.easeOut(linearProgress);
const position = easedProgress * 400;
element.style.transform = `translateX(${position}px)`;
if (linearProgress < 1) {
requestAnimationFrame(animate);
}
}
Understanding where requestAnimationFrame fits in the event loop helps you write better animations:
┌─────────────────────────────────────────────────────────────────────────┐
│ ONE EVENT LOOP ITERATION │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. Process one task (setTimeout, events, etc.) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 2. Process ALL microtasks (Promises, queueMicrotask) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 3. If time to render (usually ~60x/sec): │ │
│ │ │ │
│ │ a. Run requestAnimationFrame callbacks ◄── HERE! │ │
│ │ b. Calculate styles │ │
│ │ c. Calculate layout │ │
│ │ d. Paint to screen │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 4. requestIdleCallback (if idle time remains) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Key insight: requestAnimationFrame callbacks run right before the browser paints. This means your DOM changes are applied just in time to be rendered, with no wasted work.
Each animation approach has its place:
<Tabs> <Tab title="requestAnimationFrame"> **Best for:** - Complex animations with custom logic - Game loops - Physics simulations - Canvas/WebGL rendering - Animations depending on user input```javascript
function gameLoop(timestamp) {
handleInput();
updatePhysics();
checkCollisions();
render();
requestAnimationFrame(gameLoop);
}
```
**Pros:** Full control, frame-by-frame logic, works with canvas
**Cons:** More code, you handle everything manually
```css
.box {
transition: transform 0.3s ease-out;
}
.box:hover {
transform: scale(1.1);
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
```
**Pros:** Hardware-accelerated, declarative, less code
**Cons:** Limited control, can't do complex frame-by-frame logic
```javascript
element.animate([
{ transform: 'translateX(0)' },
{ transform: 'translateX(400px)' }
], {
duration: 1000,
easing: 'ease-out',
fill: 'forwards'
});
```
**Pros:** Best of both worlds, pause/reverse/scrub animations
**Cons:** Less browser support for advanced features
function animate({ duration, timing, draw }) {
const start = performance.now();
requestAnimationFrame(function tick(time) {
// Calculate progress (0 to 1)
let progress = (time - start) / duration;
if (progress > 1) progress = 1;
// Apply easing
const easedProgress = timing(progress);
// Draw current state
draw(easedProgress);
// Continue if not complete
if (progress < 1) {
requestAnimationFrame(tick);
}
});
}
// Usage
animate({
duration: 1000,
timing: t => t * (2 - t), // easeOut
draw: progress => {
element.style.transform = `translateX(${progress * 400}px)`;
}
});
function animateAsync({ duration, timing, draw }) {
return new Promise(resolve => {
const start = performance.now();
requestAnimationFrame(function tick(time) {
let progress = (time - start) / duration;
if (progress > 1) progress = 1;
draw(timing(progress));
if (progress < 1) {
requestAnimationFrame(tick);
} else {
resolve(); // Animation complete
}
});
});
}
// Usage with async/await
async function runAnimations() {
await animateAsync({ /* first animation */ });
await animateAsync({ /* second animation - starts after first */ });
console.log('All animations complete!');
}
class Animation {
constructor({ duration, timing, draw }) {
this.duration = duration;
this.timing = timing;
this.draw = draw;
this.elapsed = 0;
this.running = false;
this.animationId = null;
}
start() {
if (this.running) return;
this.running = true;
this.lastTime = performance.now();
this.tick();
}
pause() {
this.running = false;
cancelAnimationFrame(this.animationId);
}
tick() {
if (!this.running) return;
const now = performance.now();
this.elapsed += now - this.lastTime;
this.lastTime = now;
let progress = this.elapsed / this.duration;
if (progress > 1) progress = 1;
this.draw(this.timing(progress));
if (progress < 1) {
this.animationId = requestAnimationFrame(() => this.tick());
} else {
this.running = false;
}
}
}
// Usage
const anim = new Animation({
duration: 2000,
timing: t => t,
draw: p => element.style.opacity = p
});
startBtn.onclick = () => anim.start();
pauseBtn.onclick = () => anim.pause();
```javascript
// ❌ SLOW - triggers layout
element.style.left = position + 'px';
element.style.width = size + 'px';
// ✓ FAST - composited
element.style.transform = `translateX(${position}px)`;
element.style.opacity = alpha;
```
```css
.animated-element {
will-change: transform;
}
```
Don't overuse it though — it consumes memory.
```javascript
// ❌ BAD - read/write/read/write causes multiple layouts
element1.style.width = element2.offsetWidth + 'px';
element3.style.width = element4.offsetWidth + 'px';
// ✓ GOOD - batch reads, then batch writes
const width2 = element2.offsetWidth;
const width4 = element4.offsetWidth;
element1.style.width = width2 + 'px';
element3.style.width = width4 + 'px';
```
```javascript
// ❌ BAD - heavy work blocks rendering
function animate() {
const result = expensiveCalculation(); // 50ms of work!
render(result);
requestAnimationFrame(animate);
}
// ✓ BETTER - compute in chunks or use worker
function animate() {
render(precomputedData[currentFrame]);
currentFrame++;
requestAnimationFrame(animate);
}
```
// ❌ WRONG - only runs once!
function animate() {
element.style.left = position++ + 'px';
// Forgot to call requestAnimationFrame again!
}
requestAnimationFrame(animate);
// ✓ CORRECT
function animate() {
element.style.left = position++ + 'px';
requestAnimationFrame(animate); // Request next frame
}
requestAnimationFrame(animate);
// ❌ WRONG - moves faster on high refresh rate displays
function animate() {
position += 5; // 5px per frame
element.style.transform = `translateX(${position}px)`;
requestAnimationFrame(animate);
}
// ✓ CORRECT - use delta time
let lastTime = 0;
const speed = 300; // pixels per second
function animate(time) {
const delta = (time - lastTime) / 1000;
lastTime = time;
position += speed * delta; // Time-based movement
element.style.transform = `translateX(${position}px)`;
requestAnimationFrame(animate);
}
// ❌ WRONG - first frame has huge deltaTime (since page load!)
let lastTime = 0;
function animate(time) {
const delta = time - lastTime; // First call: delta = entire page lifetime!
lastTime = time;
// Animation jumps on first frame
}
// ✓ CORRECT - initialize lastTime properly
let lastTime = null;
function animate(time) {
if (lastTime === null) {
lastTime = time;
requestAnimationFrame(animate);
return;
}
const delta = time - lastTime;
lastTime = time;
// First actual frame has reasonable delta
}
requestAnimationFrame syncs with display refresh — it fires right before the browser paints, typically 60 times per second
Better than setInterval for animations — smoother, pauses in background tabs, battery-efficient
One-shot by design — you must call requestAnimationFrame inside your callback to keep animating
Use the timestamp parameter — it's more reliable than Date.now() or performance.now() for animation timing
Delta time prevents speed variation — multiply movement by time elapsed, not a fixed amount per frame
cancelAnimationFrame(id) stops animation — store the ID and update it every frame
Runs before paint, after microtasks — part of the rendering phase in the event loop
Animate transform and opacity — these properties are GPU-accelerated and don't trigger layout
CSS animations for simple cases — use rAF for complex logic, canvas, or game loops
Handle the first frame specially — initialize lastTime to avoid a huge delta on the first call
1. **Syncs with display refresh** — rAF fires at the optimal time before the browser paints
2. **Pauses in background tabs** — saves battery and CPU when the tab isn't visible
3. **Browser-optimized timing** — avoids dropped frames and visual jank
4. **More accurate timestamps** — provides high-resolution timestamps for smooth animations
`setInterval` doesn't know about the browser's rendering cycle, may drift, and keeps running when the tab is hidden.
Delta time is the time elapsed since the last frame. It's crucial for **frame-rate independent animations**.
```javascript
// Without delta time: 144Hz monitor runs animation 2.4x faster than 60Hz
position += 5; // 5 pixels per frame
// With delta time: same speed on all monitors
const speed = 300; // pixels per second
position += speed * deltaTime;
```
Without delta time, animations run at different speeds depending on the monitor's refresh rate.
Use `cancelAnimationFrame(id)` with the ID returned from `requestAnimationFrame`:
```javascript
let animationId;
function animate() {
// ... animation code ...
animationId = requestAnimationFrame(animate); // Update ID each frame
}
// Start
animationId = requestAnimationFrame(animate);
// Stop
cancelAnimationFrame(animationId);
```
Important: Update `animationId` inside the animate function, not just when starting.
It runs during the **rendering phase** of the event loop, specifically:
1. After the current task completes
2. After all microtasks are drained
3. **Before the browser calculates styles, layout, and paints**
This timing ensures your DOM changes are applied right before they're rendered to screen.
Animate `transform` and `opacity` — these are compositor-only properties that don't trigger layout or paint:
```javascript
// ✓ Fast (compositor only)
element.style.transform = 'translateX(100px)';
element.style.transform = 'scale(1.2)';
element.style.transform = 'rotate(45deg)';
element.style.opacity = 0.5;
// ❌ Slow (triggers layout)
element.style.left = '100px';
element.style.width = '200px';
element.style.margin = '10px';
```
Layout-triggering properties force the browser to recalculate positions of other elements every frame.
Initialize `lastTime` to `null` and skip the first frame's animation:
```javascript
let lastTime = null;
function animate(time) {
if (lastTime === null) {
lastTime = time;
requestAnimationFrame(animate);
return; // Skip first frame
}
const delta = (time - lastTime) / 1000;
lastTime = time;
// Now delta is reasonable (16.67ms at 60fps)
position += speed * delta;
requestAnimationFrame(animate);
}
```
Without this, the first delta would be the time since page load, causing a huge jump.