π Animation Performance Optimization
A gorgeous animation that stutters is worse than no animation at all. In this lesson you'll learn why some animations are cheap and others are ruinously expensive β by following a frame through the browser's rendering pipeline β and the concrete techniques that keep motion locked at a silky 60fps, even on a budget phone.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Trace a frame through the rendering pipeline: JS β Style β Layout β Paint β Composite
- Classify properties as layout, paint, or composite and pick the cheapest
- Use
will-changeand compositor layers correctly β and know when not to - Cut animation workload with staggering and
IntersectionObserver - Profile animations in DevTools and reason about a 16.7ms frame budget
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Diagnose and refactor a janky card-hover animation from layout-thrashing to GPU-composited.
In This Lesson
Why Performance Matters
Screens typically refresh 60 times per second, which gives the browser roughly 16.7 milliseconds to produce each frame. Miss that deadline and the frame is dropped β the eye perceives it as a stutter or "jank." Poorly built animations don't just look bad; they:
- drain battery and heat up phones, triggering CPU throttling;
- make unrelated interactions (scrolling, typing) feel laggy;
- hurt real users hardest on the low-end devices that make up much of the world's traffic.
π‘ The core idea: Performance optimization is about doing less work per frame. A well-tuned animation runs smoothly for the same reason a well-tuned engine sips fuel β it avoids unnecessary effort at every step.
π Key Terms
Frame budget: the ~16.7ms available to build one frame at 60fps. Everything β JS, style, layout, paint, composite β must fit inside it.
Jank: visible stutter caused by frames that took longer than the budget and were dropped.
Compositor: a separate browser thread that can move and fade existing layers on the GPU without re-running layout or paint.
The Rendering Pipeline
To make a frame, the browser runs an assembly line of five stages. Which stages fire depends on what you changed:
- JavaScript β a script or the animation engine changes something.
- Style β the browser recomputes which CSS rules apply.
- Layout (reflow) β it recalculates the geometry: size and position of every affected element. Changing one element's width can shift its siblings, so this cascades.
- Paint β it fills in pixels: text, colors, borders, shadows, into layers.
- Composite β it assembles the painted layers onto the screen, on the GPU.
The crucial insight: the earlier a stage runs, the more work it forces after it. Trigger Layout and Paint and Composite must follow. Trigger only Composite and the browser skips the two most expensive stages entirely.
Cheap vs. Expensive Properties
Every animatable property enters the pipeline at a particular stage. That single fact decides its cost:
width, height, top, left, margin"] A --> C["Paint group
color, background, box-shadow"] A --> D["Composite group
transform, opacity"] B --> E["Layout + Paint + Composite
β most expensive"] C --> F["Paint + Composite
β οΈ moderate"] D --> G["Composite only
β cheapest, GPU"]
The takeaway that will carry you through your whole career: animate transform and opacity whenever you possibly can. Almost any motion has a transform equivalent:
| Instead of animating⦠| Animate⦠|
|---|---|
left / right | transform: translateX() |
top / bottom | transform: translateY() |
width / height | transform: scale() |
| rotation via layout tricks | transform: rotate() |
| show / hide | opacity |
/* β Poor: animates layout every frame (reflow) */
@keyframes slide-bad {
from { left: 0; width: 100px; }
to { left: 200px; width: 150px; }
}
/* β
Good: composite-only, runs on the GPU */
@keyframes slide-good {
from { transform: translateX(0) scaleX(1); }
to { transform: translateX(200px) scaleX(1.5); }
}
It's the difference between remodeling a room (layout), repainting the walls (paint), and simply sliding the furniture around (composite). The last one is effortless by comparison.
β οΈ Watch out for layout thrashing in JS
Reading a layout property (offsetTop, getBoundingClientRect()) right after writing one forces the browser to reflow synchronously, mid-frame. In loops this "layout thrashing" is a classic jank source. Batch all reads first, then all writes β or let requestAnimationFrame schedule the write.
will-change & Compositor Layers
Moving an element onto its own compositor layer lets the GPU transform and fade it without disturbing anything else. The modern, explicit way to hint this is will-change:
.card {
will-change: transform; /* browser: prepare a layer for transforms */
}
This is like telling a chef which ingredients you'll need next so they're prepped and ready before you start cooking. But it comes at a cost β each layer consumes memory β so it must be used surgically.
β Good practice
- Apply
will-changeshortly before an animation (e.g. on the parent's:hover), not permanently. - Name only the specific properties that will change.
- Remove it (set back to
auto) once the animation is done. - Reach for it only after profiling shows a real problem.
β οΈ Bad practice
/* Don't blanket the whole page β this exhausts memory */
* { will-change: transform, opacity, left, top; }
Over-promoting elements creates hundreds of layers the compositor must juggle, which is slower than having none. If everything is a priority, nothing is.
You may still see the old transform: translateZ(0) ("null transform") hack used to force a layer. will-change is the clearer, purpose-built replacement β prefer it, and in most cases simply animating transform/opacity is enough on its own without any hint at all.
Reducing Workload
Stagger to spread the load
Animating twenty elements at the exact same instant creates one big spike of work. Small per-element delays smooth that spike into a gentle ramp β and look better too:
.item { animation: fade-in-up 0.5s ease-out backwards; }
.item:nth-child(1) { animation-delay: 0.00s; }
.item:nth-child(2) { animation-delay: 0.05s; }
.item:nth-child(3) { animation-delay: 0.10s; }
.item:nth-child(4) { animation-delay: 0.15s; }
Pause offscreen animations
An animation the user can't see still burns CPU and battery. IntersectionObserver lets you pause anything scrolled out of view and resume it when it returns:
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
entry.target.style.animationPlayState =
entry.isIntersecting ? 'running' : 'paused';
}
});
document.querySelectorAll('.animated').forEach(el => io.observe(el));
Why power a light show in an empty room? Turn it off until someone walks in.
Trigger entrance animations lazily
The same observer pattern is the modern way to animate elements as they enter the viewport β far cheaper and jank-free compared with recalculating positions on every scroll event:
const reveal = new IntersectionObserver((entries, obs) => {
entries.forEach((entry, i) => {
if (!entry.isIntersecting) return;
// small stagger, then stop watching this element
entry.target.style.animationDelay = `${i * 50}ms`;
entry.target.classList.add('is-visible');
obs.unobserve(entry.target);
});
}, { threshold: 0.1 });
document.querySelectorAll('.reveal').forEach(el => reveal.observe(el));
Prefer opacity over expensive paint
Animating box-shadow repaints every frame. A common trick is to pre-render the "hovered" shadow on a pseudo-element and animate its opacity instead β a composite-only operation with the same visual result:
.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
box-shadow: 0 15px 25px rgba(0, 0, 0, 0.2);
opacity: 0;
transition: opacity 0.3s;
z-index: -1;
}
.card:hover::after { opacity: 1; }
Measuring in DevTools
Never optimize by guesswork β measure. Chrome and Firefox DevTools show you exactly which stages your animation triggers.
π‘ A quick profiling routine (Chrome)
- Performance panel: click Record, interact with the animation, stop. Red-flagged frames exceeded the budget; look for tall "Layout" and "Paint" bars.
- Rendering tab (Esc β drawer β Rendering): enable Paint flashing to see repaints flash green, and Layer borders to visualize compositor layers.
- FPS meter: enable "Frame Rendering Stats" to watch the live frame rate as you interact.
If you want a lightweight in-page readout, a requestAnimationFrame loop makes a serviceable FPS counter:
let frames = 0, last = performance.now();
function tick(now) {
frames++;
if (now - last >= 1000) {
console.log(`FPS: ${Math.round((frames * 1000) / (now - last))}`);
frames = 0;
last = now;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
A rough performance budget
- Target a steady 60fps β about 16.7ms per frame, of which your scripting should use only a slice.
- Keep the number of animated compositor layers small (a handful, not hundreds).
- Always test on a real low-end device or DevTools CPU throttling β desktops hide problems phones expose.
A budget, like a financial one, turns vague intentions into concrete decisions about what you can afford.
Hands-on Exercise
ποΈ Refactor a Janky Card Hover
Objective: Take an animation that thrashes layout and paint, and rebuild it to run composite-only.
The problem code
.card:hover {
animation: card-effect 1s infinite alternate;
}
@keyframes card-effect {
0% {
left: 0; top: 0; width: 200px; height: 200px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
background-color: white;
}
100% {
left: -10px; top: -10px; width: 220px; height: 220px;
box-shadow: 0 12px 24px rgba(0,0,0,0.2);
background-color: #f8f8f8;
}
}
Your task
- Identify every property being animated and classify it as layout, paint, or composite.
- Rewrite the animation to produce a similar "lift and grow" effect using only
transformandopacity. - Move the growing shadow onto a pseudo-element and cross-fade it with opacity.
- Profile before and after with Paint Flashing to confirm the repaints are gone.
π‘ Hint
The left/top move is translate(-10px, -10px). The width/height growth from 200β220 is scale(1.1). The shadow can't be transformed, so layer it on ::after and animate that layer's opacity from 0 to 1.
β Solution
.card {
position: relative;
width: 200px; height: 200px;
background: var(--card-bg);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
will-change: transform; /* hint only the animated card */
}
/* Pre-rendered "lifted" shadow, faded in on hover */
.card::after {
content: "";
position: absolute;
inset: 0;
box-shadow: 0 12px 24px rgba(0,0,0,0.2);
opacity: 0;
transition: opacity 0.3s ease;
z-index: -1;
}
.card:hover {
transform: translate(-10px, -10px) scale(1.1); /* composite only */
}
.card:hover::after {
opacity: 1; /* composite only */
}
Every animated property is now composite-only: no reflow, no repaint on the card itself. On a page of 20 cards this typically turns a stuttery hover into a perfectly smooth one and cuts CPU use dramatically.
π― Quick Quiz
Question 1: Which properties can be animated on the compositor alone, skipping layout and paint?
Question 2: What is the safest way to use will-change?
Question 3: Roughly how long is one frame's budget at 60fps?
Summary & Quiz
π Key Takeaways
- Each frame has a ~16.7ms budget; optimization is about doing less work inside it.
- The pipeline is JS β Style β Layout β Paint β Composite; the later you enter, the cheaper the frame.
- Animate
transformandopacity(composite-only); avoid layout properties likewidth,top,left. - Use
will-changesurgically β specific property, just before animating, removed after. - Cut workload by staggering and pausing offscreen animations with
IntersectionObserver; always profile in DevTools on real devices.
π Further Reading
- web.dev β Animations and performance
- MDN β CSS and JavaScript animation performance
- MDN β will-change reference
π What's Next?
You've finished the animation arc of Module 6. Next we zoom out from individual effects to how you organize CSS at scale, comparing the BEM, SMACSS, and OOCSS methodologies that keep large stylesheets β animations included β maintainable.
π Smooth as silk!
Your animations now respect the frame budget. Let's bring the same discipline to your CSS architecture.