requestAnimationFrame: Building Smooth Loops
requestAnimationFrame (rAF) is the standard browser API for synchronizing animation loops with the display refresh rate. Unlike setInterval (which ignores the refresh rate) or setTimeout (which has coarse timing), requestAnimationFrame fires before each frame and ensures your animation code runs at the optimal time. This article teaches you how to build rAF-driven animations in React, implement easing functions, and avoid common pitfalls that cause jank.
How requestAnimationFrame Works
requestAnimationFrame takes a callback function and queues it to run before the next frame is painted. The callback receives a timestamp (in milliseconds since the page loaded) that you use to calculate animation progress. Here is the basic pattern:
let animationId = null;
function animate(currentTime) {
// Do something with currentTime
console.log(currentTime);
// Queue the next frame
animationId = requestAnimationFrame(animate);
}
// Start the animation
animationId = requestAnimationFrame(animate);
// Cancel when done
cancelAnimationFrame(animationId);
The timestamp is high-resolution (microsecond precision), making it ideal for precise animations. Unlike setInterval, which fires every N milliseconds regardless of whether the browser is ready to paint, requestAnimationFrame only fires when the browser is about to draw a frame.
Building a Simple Animation Loop in React
Here is a React component that animates a progress bar from 0 to 100% over 2 seconds using rAF:
import { useState, useEffect, useRef } from 'react';
export default function ProgressBarAnimation() {
const [progress, setProgress] = useState(0);
const animationRef = useRef(null);
const startTimeRef = useRef(null);
useEffect(() => {
const animate = (currentTime) => {
// Set start time on the first frame
if (startTimeRef.current === null) {
startTimeRef.current = currentTime;
}
const elapsed = currentTime - startTimeRef.current;
const duration = 2000; // 2 seconds in milliseconds
const rawProgress = Math.min(elapsed / duration, 1);
setProgress(rawProgress);
// Continue animating if not finished
if (rawProgress < 1) {
animationRef.current = requestAnimationFrame(animate);
}
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
return (
<div>
<div
style={{
width: `${progress * 100}%`,
height: '20px',
backgroundColor: '#4ecdc4',
transition: 'none', // Disable CSS transition to avoid interference
}}
/>
<p>{Math.round(progress * 100)}%</p>
</div>
);
}
This component animates from 0 to 100% linearly over 2 seconds. The key points:
- Capture the start time on the first frame.
- Calculate elapsed time and normalize it to a 0–1 progress value.
- Clamp progress to 1 (in case the browser runs rAF one more time after completion).
- Cancel the animation ID in the cleanup function to prevent memory leaks.
Implementing Easing Functions
Linear motion (constant speed) feels robotic. Real animations accelerate and decelerate using easing functions. Here are common easing implementations:
// Easing functions: all take a 0-1 progress value and return a 0-1 eased value
const easing = {
// Linear (no easing)
linear: (t) => t,
// Ease-in: slow start
easeInQuad: (t) => t * t,
easeInCubic: (t) => t * t * t,
// Ease-out: slow end
easeOutQuad: (t) => 1 - (1 - t) * (1 - t),
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
// Ease-in-out: slow start and end
easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
easeInOutCubic: (t) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
// Bounce-out: elastic bounce at the end
easeOutBounce: (t) => {
const n1 = 7.5625;
const d1 = 2.75;
if (t < 1 / d1) {
return n1 * t * t;
} else if (t < 2 / d1) {
return n1 * (t -= 1.5 / d1) * t + 0.75;
} else if (t < 2.5 / d1) {
return n1 * (t -= 2.25 / d1) * t + 0.9375;
} else {
return n1 * (t -= 2.625 / d1) * t + 0.984375;
}
},
};
Now apply an easing function to the animation:
import { useState, useEffect, useRef } from 'react';
const easing = {
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
};
export default function EasedAnimation() {
const [scale, setScale] = useState(1);
const animationRef = useRef(null);
const startTimeRef = useRef(null);
useEffect(() => {
const animate = (currentTime) => {
if (startTimeRef.current === null) {
startTimeRef.current = currentTime;
}
const elapsed = currentTime - startTimeRef.current;
const duration = 600;
const rawProgress = Math.min(elapsed / duration, 1);
// Apply easing to the raw progress
const easedProgress = easing.easeOutCubic(rawProgress);
// Animate from 1 to 1.5
setScale(1 + easedProgress * 0.5);
if (rawProgress < 1) {
animationRef.current = requestAnimationFrame(animate);
}
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
return (
<div
style={{
transform: `scale(${scale})`,
width: '100px',
height: '100px',
backgroundColor: '#4ecdc4',
transformOrigin: 'center',
}}
/>
);
}
This component scales from 1 to 1.5 with a smooth ease-out cubic curve, making the motion feel natural and polished.
Coordinating Multiple Properties
Animate multiple properties simultaneously by updating them in a single rAF callback:
import { useState, useEffect, useRef } from 'react';
const easing = {
easeOutCubic: (t) => 1 - Math.pow(1 - t, 3),
};
export default function MultiPropertyAnimation() {
const [translateX, setTranslateX] = useState(0);
const [opacity, setOpacity] = useState(0);
const [rotate, setRotate] = useState(0);
const animationRef = useRef(null);
const startTimeRef = useRef(null);
useEffect(() => {
const animate = (currentTime) => {
if (startTimeRef.current === null) {
startTimeRef.current = currentTime;
}
const elapsed = currentTime - startTimeRef.current;
const duration = 1000;
const rawProgress = Math.min(elapsed / duration, 1);
const easedProgress = easing.easeOutCubic(rawProgress);
// Animate all three properties in sync
setTranslateX(easedProgress * 300);
setOpacity(easedProgress);
setRotate(easedProgress * 360);
if (rawProgress < 1) {
animationRef.current = requestAnimationFrame(animate);
}
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
return (
<div
style={{
transform: `translateX(${translateX}px) rotate(${rotate}deg)`,
opacity,
width: '100px',
height: '100px',
backgroundColor: '#4ecdc4',
}}
/>
);
}
Avoiding Main-Thread Blocking
rAF callbacks run on the main JavaScript thread, which also handles events, React renders, and other tasks. If your animation callback takes more than a few milliseconds, it can block other work:
// Bad: blocking animation callback
const animate = (currentTime) => {
// Expensive computation (blocks the frame)
for (let i = 0; i < 1000000000; i++) {
Math.sqrt(i);
}
setPosition(calculatePosition(currentTime));
animationRef.current = requestAnimationFrame(animate);
};
// Good: offload expensive work to Web Worker or break it into chunks
const animate = (currentTime) => {
// Fast calculation (< 1ms)
setPosition(easedValue(currentTime));
animationRef.current = requestAnimationFrame(animate);
};
If your animation involves calculations that take more than 2–3 milliseconds per frame, consider:
- Simplifying the calculation
- Pre-computing values before the animation starts
- Moving expensive work to a Web Worker
Key Takeaways
requestAnimationFramesynchronizes your animation callback with the browser's refresh rate, ensuring optimal timing.- Capture the start time on the first frame to calculate elapsed time and progress as 0–1.
- Easing functions make animations feel natural by simulating acceleration and deceleration.
- Coordinate multiple animated properties in a single rAF callback to keep them in sync.
- Always cancel rAF callbacks in the cleanup function to prevent memory leaks and unnecessary work.
- Keep rAF callbacks fast (under 2–3 ms) to avoid blocking user input or React renders.
Frequently Asked Questions
What is the difference between requestAnimationFrame and setTimeout?
requestAnimationFrame fires before the browser paints a frame and respects the display refresh rate (60 fps or 120 fps). setTimeout fires after a fixed delay and ignores the refresh rate, causing animations to stutter if the setTimeout interval does not align with frame boundaries. Always use requestAnimationFrame for animations.
Can I use rAF in a Web Worker?
No. requestAnimationFrame is only available in the main thread (window context). Web Workers do not have access to it. If you need to animate from a Web Worker, use postMessage to send updates to the main thread, which then schedules rAF callbacks.
Does requestAnimationFrame pause when the tab is backgrounded?
Yes. Most browsers pause requestAnimationFrame when the tab loses focus to save CPU and battery. If you need background animations, use setInterval (but this sacrifices the 60 fps guarantee). For most use cases, pausing is acceptable.
How do I know if my rAF callback exceeded the frame budget?
In Chrome DevTools, open the Performance tab, record while animating, and look at the flame chart. If a single animation frame takes more than 16.67 ms (at 60 Hz) or 8.33 ms (at 120 Hz), the frame dropped. Use the Console to log performance.now() at the start and end of your callback to measure precise execution time.
Can I request two rAF frames for the same element?
You can queue multiple rAF callbacks, but they all run in sequence before the paint. If you queue 1000 callbacks, they will all execute before the frame is painted, potentially blocking the frame. Keep the number of queued callbacks low (ideally 1 per animated element).