Web Animation Performance — The Complete Developer's Guide (2025)
Nothing kills a beautiful animation faster than jank. A 60fps animation feels like glass. A 30fps animation feels like a PowerPoint. A janky 15fps animation makes users question whether their device is broken.
This guide explains exactly why animations get slow, how the browser renders motion, and the specific techniques that keep animations buttery smooth — even on mid-range mobile devices.
Understanding the Browser Rendering Pipeline
Before you can optimize animations, you need to understand what the browser does to display each frame.
The Pixel Pipeline
Every frame goes through up to five stages:
JavaScript → Style → Layout → Paint → Composite- JavaScript — Your JS runs (event handlers, animations, RAF callbacks)
- Style — The browser recalculates CSS styles for changed elements
- Layout — Geometry is recalculated (positions, sizes). Most expensive step.
- Paint — Pixels are drawn into layers
- Composite — Layers are assembled and sent to the screen
The key insight: Skipping Layout and Paint is the goal. If you animate properties that only affect the Composite step, the browser skips the two most expensive stages.
The Magic Properties
Only two CSS properties skip both Layout and Paint:
| Property | What it does | GPU-Accelerated |
|---|---|---|
transform | Move, scale, rotate, skew | ✅ Yes |
opacity | Transparency | ✅ Yes |
Everything else — width, height, top, left, background-color, border-width, font-size — triggers Layout or Paint (or both), causing the browser to re-render large portions of the page.
The 60fps Target
Your display refreshes at 60Hz (most devices) or 90–120Hz (modern phones). That means you have 16.67ms to render each frame at 60fps.
The browser needs about 6ms of that budget for its own overhead, leaving you ~10ms for JavaScript and rendering work.
If any frame takes longer than 16.67ms, you drop a frame. The user sees a stutter.
Measuring Frame Rate
// Manual FPS counter
let lastTime = performance.now();
let frames = 0;
function tick(now: number) {
frames++;
if (now - lastTime >= 1000) {
console.log(`${frames} fps`);
frames = 0;
lastTime = now;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);Or use Chrome DevTools:
- Open DevTools → Performance tab
- Click the ⚙ icon → Enable "FPS meter"
- The live FPS counter appears in the top-right of the viewport
The 10 Golden Rules of Animation Performance
Rule 1: Only Animate transform and opacity
This cannot be overstated. Every other animated property costs you.
/* ❌ SLOW — triggers Layout */
.box {
animation: slide-wrong 0.3s ease;
}
@keyframes slide-wrong {
from { left: -100px; }
to { left: 0; }
}
/* ✅ FAST — GPU only */
.box {
animation: slide-right 0.3s ease;
}
@keyframes slide-right {
from { transform: translateX(-100px); }
to { transform: translateX(0); }
}Rule 2: Promote Heavy Animations to Their Own Layer
.animation-heavy {
will-change: transform, opacity;
/* Or the older hack: */
transform: translateZ(0);
}will-change tells the browser to promote the element to its own compositor layer before the animation starts, avoiding the cost of layer creation mid-animation.
Warning: Don't apply will-change to everything. Each promoted layer consumes GPU memory. On a page with 50 animated elements, this can cause memory pressure and actually slow things down.
Rule of thumb: Only use will-change on elements that animate continuously (backgrounds, looping effects) or are about to animate (add class on hover, remove after transition).
Rule 3: Use requestAnimationFrame for JS Animations
Never animate with setInterval or setTimeout. These don't sync to the display refresh rate.
// ❌ Jank city
setInterval(() => {
element.style.transform = `translateX(${x++}px)`;
}, 16);
// ✅ Smooth as butter
function animate(timestamp: number) {
element.style.transform = `translateX(${position}px)`;
position += speed;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);Rule 4: Avoid Forced Synchronous Layout (Layout Thrashing)
Layout thrashing happens when you read a layout property (triggering layout calculation) then immediately write one (invalidating the calculation), in a loop.
// ❌ Triggers layout on every iteration
elements.forEach(el => {
const height = el.offsetHeight; // READ — forces layout
el.style.height = `${height + 10}px`; // WRITE — invalidates layout
});
// ✅ Batch reads then writes
const heights = elements.map(el => el.offsetHeight); // All reads first
elements.forEach((el, i) => {
el.style.height = `${heights[i] + 10}px`; // Then all writes
});Libraries like FastDOM can help automate this batching.
Rule 5: Debounce Scroll and Resize Handlers
Scroll fires up to 60× per second. Resize fires on every pixel change. Heavy work inside these handlers destroys performance.
// ❌ Runs expensive work 60x per second
window.addEventListener('scroll', () => {
expensiveAnimation();
});
// ✅ Throttled to ~10x per second
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
expensiveAnimation();
ticking = false;
});
ticking = true;
}
});Rule 6: Use content-visibility for Off-Screen Content
For pages with lots of content below the fold, content-visibility: auto tells the browser to skip rendering off-screen sections entirely — including their animations.
.page-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated height */
}This can reduce initial render time by 40–70% on content-heavy pages.
Rule 7: Pause Animations When Not Visible
Animations running in inactive tabs waste battery and CPU. Use the Page Visibility API to pause them:
document.addEventListener('visibilitychange', () => {
const els = document.querySelectorAll('.animated');
els.forEach(el => {
el.style.animationPlayState =
document.hidden ? 'paused' : 'running';
});
});Rule 8: Avoid filter: blur() on Large Elements
filter: blur() is GPU-accelerated — but it's one of the most expensive GPU operations. A blur filter on a full-viewport element causes the GPU to process every pixel.
If you need blur backgrounds (glassmorphism), use it on small elements or apply it to a copy of the background rather than the background itself:
/* ❌ Expensive — blur applied to large background */
.hero {
background: url(image.jpg);
filter: blur(20px);
}
/* ✅ Better — blur applied to a small overlay */
.glass-card {
backdrop-filter: blur(20px);
/* backdrop-filter only blurs what's *behind* the element */
}Rule 9: Choose the Right Easing Function
Easing functions affect how smooth an animation feels — even at the same FPS.
/* Built-in easings */
animation-timing-function: ease; /* standard */
animation-timing-function: ease-in-out; /* natural */
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* spring-like */
/* CSS spring (new in 2024) */
animation-timing-function: linear(
0, 0.009, 0.035 2.1%, 0.141, 0.281 6.7%, 0.723 12.9%, 0.938, 1.017,
1.077, 1.121, 1.149 24.3%, 1.159, 1.163, 1.157, 1.145 34%, 1.007 45.2%,
0.988, 0.979, 0.975 53.8%, 0.975 55%, 0.978 58%, 1
);For UI interactions (buttons, menus, modals), use ease-out — it feels instant at the start and settles smoothly at the end. This matches how physical objects decelerate.
Rule 10: Respect prefers-reduced-motion
Not an optimization per se, but critical for accessibility and a signal of craft:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}In React/Next.js, check for this preference in JS too:
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (!prefersReducedMotion) {
startAnimation();
}Measuring Animation Performance with Lighthouse
Lighthouse scores animations under these metrics:
- Total Blocking Time (TBT) — Long animation JS tasks that block the main thread
- Cumulative Layout Shift (CLS) — Layout shifts caused by animated elements
- Largest Contentful Paint (LCP) — If your animated hero delays the main content
Fixing CLS from Animations
Layout shift during animation is a common LCP/CLS problem. If your animated element changes size during the animation, reserve its space upfront:
.animated-card {
/* Reserve space before animation starts */
min-height: 300px;
contain: layout; /* Prevents animation from affecting surrounding elements */
}Fixing LCP from Animated Heroes
If your hero section has a full-screen video or canvas animation, the LCP element (usually the H1 headline) can be delayed while the animation initializes. Fix this by:
- Rendering the headline with
position: relative; z-index: 10above the animation - Lazy-loading the animation background
- Using a static poster image as an
::beforepseudo-element that fades out once the animation loads
Debugging Jank: A Systematic Approach
When an animation stutters, follow this 5-step diagnostic:
Step 1: Open the Performance Panel
DevTools → Performance → Click Record → Reproduce the jank → Stop recording
Step 2: Find Long Frames
Look for frames taller than the 16ms line in the flame chart. These are your dropped frames.
Step 3: Identify the Cause
Click on a long frame to see which tasks took time:
- Recalculate Style → You're changing too many CSS properties
- Layout → You're animating non-transform properties
- Paint → You have expensive paint operations (shadows, gradients on non-layers)
- Script → Your JavaScript is doing too much per frame
Step 4: Check the Layers Panel
DevTools → Rendering → Paint flashing (shows what's being repainted in red) DevTools → Layers (shows compositor layers — too many is also bad)
Step 5: Fix and Retest
Common fixes:
| Symptom | Fix |
|---|---|
Animating left/top | Switch to transform: translate() |
| Constant full-page repaint | Add will-change: transform to animated elements |
| JS animation jank | Move to CSS animations or Web Animations API |
| Layout thrashing in scroll handler | Throttle with RAF + batch reads/writes |
| Canvas animation dropping frames | Reduce particle count, use OffscreenCanvas |
Quick Reference Card
ALWAYS SAFE TO ANIMATE:
✅ transform: translate, scale, rotate, skew
✅ opacity
SAFE WITH GPU PROMOTION (will-change):
✅ filter (on small elements)
✅ backdrop-filter
AVOID ANIMATING:
❌ width, height, min/max-width/height
❌ top, left, right, bottom
❌ margin, padding
❌ border-width
❌ background-color (use opacity layers instead)
❌ font-size, line-height
❌ box-shadow (expensive — use opacity on a pseudo-element)
TOOLS:
Chrome DevTools Performance panel
Chrome DevTools Rendering → FPS Meter
Chrome DevTools Layers panel
Lighthouse (Core Web Vitals)Production-Ready Animations Without the Guesswork
Optimizing animations from scratch requires deep knowledge of the rendering pipeline, browser quirks, and device capabilities. The Animation Web library does this work for you.
Every template is:
- Benchmarked at 60fps on mid-range Android devices
- Only uses
transformandopacity(unless otherwise noted) - Includes
prefers-reduced-motionfallbacks - Lighthouse-tested for zero CLS impact
Browse the Animation Web Library and ship performant, beautiful animations today.