š¬
Intermediate
10 min readRequestAnimationFrame
Smooth animations and optimal rendering performance
Understanding RequestAnimationFrame
RequestAnimationFrame (RAF) is the browser's native API for running smooth animations at 60fps. It synchronizes your code with the browser's repaint cycle, preventing wasted work and ensuring buttery-smooth performance.
Why RequestAnimationFrame?
// ā Bad: Using setInterval for animations
let position = 0;
setInterval(() => {
position += 5;
element.style.left = position + 'px';
}, 16); // Attempting 60fps
// Problems:
// - Not synced with browser refresh rate
// - Continues when tab is inactive (wastes CPU)
// - Inconsistent frame timing
// - Can cause screen tearing
// ā
Good: Using requestAnimationFrame
let position = 0;
function animate() {
position += 5;
element.style.left = position + 'px';
if (position < 500) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
// Benefits:
// - Synced with browser (60fps or 120fps on high-refresh displays)
// - Pauses when tab is inactive
// - Consistent timing
// - Prevents screen tearing
Animation Loop with Timestamp
// Use timestamp for frame-independent animations
function animateWithTimestamp(timestamp) {
// timestamp is automatically provided by RAF
const progress = (timestamp % 2000) / 2000; // 2s loop
const position = progress * 500; // 0 to 500px
element.style.transform = `translateX(${position}px)`;
requestAnimationFrame(animateWithTimestamp);
}
requestAnimationFrame(animateWithTimestamp);
// Frame-independent movement
class Animation {
constructor() {
this.startTime = null;
this.duration = 1000; // 1 second
}
start() {
this.startTime = null;
requestAnimationFrame((timestamp) => this.step(timestamp));
}
step(timestamp) {
if (!this.startTime) this.startTime = timestamp;
const elapsed = timestamp - this.startTime;
const progress = Math.min(elapsed / this.duration, 1);
// Easing function
const eased = this.easeInOutCubic(progress);
// Update element
element.style.transform = `translateX(${eased * 500}px)`;
if (progress < 1) {
requestAnimationFrame((t) => this.step(t));
}
}
easeInOutCubic(t) {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
}
}
const animation = new Animation();
animation.start();
Canceling Animations
// Store animation ID to cancel later
let animationId;
function animate() {
// Animation logic
updatePosition();
animationId = requestAnimationFrame(animate);
}
// Start animation
animationId = requestAnimationFrame(animate);
// Stop animation
function stopAnimation() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
}
// Example: Stop on user interaction
button.addEventListener('click', stopAnimation);
Performance Optimization Patterns
// 1. Batch DOM reads and writes
// ā Bad: Layout thrashing
function animateBad() {
elements.forEach(el => {
const top = el.offsetTop; // Read (forces layout)
el.style.top = top + 1 + 'px'; // Write (invalidates layout)
});
requestAnimationFrame(animateBad);
}
// ā
Good: Batch operations
function animateGood() {
// Batch all reads first
const positions = elements.map(el => el.offsetTop);
// Then batch all writes
elements.forEach((el, i) => {
el.style.top = positions[i] + 1 + 'px';
});
requestAnimationFrame(animateGood);
}
// 2. Use transform instead of layout properties
// ā Bad: Triggers layout
function moveElementBad(x, y) {
element.style.left = x + 'px';
element.style.top = y + 'px';
}
// ā
Good: Only triggers composite
function moveElementGood(x, y) {
element.style.transform = `translate(${x}px, ${y}px)`;
}
// 3. Avoid expensive operations in animation loop
function animate(timestamp) {
// ā Bad: Expensive calculations every frame
const data = calculateComplexData();
const result = processLargeDataset(data);
updateUI(result);
// ā
Good: Calculate once, animate many times
// Or move to Web Worker
requestAnimationFrame(animate);
}
Particle System Example
class ParticleSystem {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.particles = [];
this.running = false;
}
createParticle() {
return {
x: Math.random() * this.canvas.width,
y: Math.random() * this.canvas.height,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
radius: Math.random() * 3 + 1,
life: 1
};
}
start() {
this.running = true;
// Create initial particles
for (let i = 0; i < 100; i++) {
this.particles.push(this.createParticle());
}
this.animate();
}
animate() {
if (!this.running) return;
// Clear canvas
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Update and draw particles
this.particles = this.particles.filter(particle => {
// Update position
particle.x += particle.vx;
particle.y += particle.vy;
particle.life -= 0.01;
// Draw particle
this.ctx.beginPath();
this.ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(255, 255, 255, ${particle.life})`;
this.ctx.fill();
// Keep alive particles
return particle.life > 0;
});
// Add new particles
if (this.particles.length < 100) {
this.particles.push(this.createParticle());
}
requestAnimationFrame(() => this.animate());
}
stop() {
this.running = false;
}
}
const system = new ParticleSystem(document.getElementById('canvas'));
system.start();
Measuring Animation Performance
class PerformanceMonitor {
constructor() {
this.frames = [];
this.lastTime = performance.now();
}
measure(timestamp) {
// Calculate FPS
const delta = timestamp - this.lastTime;
const fps = 1000 / delta;
this.frames.push(fps);
// Keep last 60 frames
if (this.frames.length > 60) {
this.frames.shift();
}
this.lastTime = timestamp;
// Calculate average FPS
const avgFps = this.frames.reduce((a, b) => a + b, 0) / this.frames.length;
// Warn if dropping below 60fps
if (avgFps < 55) {
console.warn('Performance issue: Average FPS is', avgFps.toFixed(2));
}
return avgFps;
}
}
const monitor = new PerformanceMonitor();
function animate(timestamp) {
const fps = monitor.measure(timestamp);
// Your animation logic
updateScene();
// Display FPS
fpsDisplay.textContent = `FPS: ${fps.toFixed(1)}`;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
Scroll-Based Animations
// Smooth scroll-based parallax
class ParallaxEffect {
constructor() {
this.ticking = false;
this.scrollY = 0;
window.addEventListener('scroll', () => this.onScroll());
}
onScroll() {
this.scrollY = window.scrollY;
if (!this.ticking) {
requestAnimationFrame(() => this.update());
this.ticking = true;
}
}
update() {
// Apply parallax effect
const layers = document.querySelectorAll('.parallax-layer');
layers.forEach((layer, index) => {
const speed = (index + 1) * 0.2;
const yPos = -(this.scrollY * speed);
layer.style.transform = `translateY(${yPos}px)`;
});
this.ticking = false;
}
}
new ParallaxEffect();
Best Practices
- Always use
requestAnimationFrameinstead ofsetIntervalfor animations - Use timestamps for frame-independent animations
- Animate
transformandopacityproperties only when possible - Batch DOM reads before writes to avoid layout thrashing
- Store RAF IDs to cancel animations properly
- Use
will-changeCSS property sparingly for better compositing - Profile animations to ensure 60fps (16.67ms per frame)
- Throttle scroll events - use RAF to batch updates
- Consider
IntersectionObserverto pause off-screen animations - Use CSS animations/transitions for simple effects