Episode 1 — Fundamentals / 1.12 — CSS Animations and Motion Design
1.12.e — Animation Performance
In one sentence: Smooth 60 fps animation means finishing all rendering work in 16.67 ms per frame — and the fastest way to hit that budget is to animate only compositor-only properties (
transformandopacity) so the browser skips layout and paint entirely.
Navigation: ← 1.12.d — Keyframe Animations · 1.12 Overview →
1. The rendering pipeline revisited
Every visual change passes through up to three stages:
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Layout │ ──► │ Paint │ ──► │ Composite │
│ (reflow) │ │ (pixels) │ │ (GPU layers) │
└──────────┘ └──────────┘ └──────────────┘
| What you animate | Stages triggered | Cost |
|---|---|---|
width, height, margin, padding, top, left | Layout → Paint → Composite | Expensive |
color, background-color, box-shadow, border | Paint → Composite | Moderate |
transform, opacity | Composite only | Cheap |
The golden rule: if you can express a visual change with
transformoropacity, do it. Everything else forces the main thread to recalculate geometry or re-paint pixels.
2. Why layout-triggering animations are expensive
Animating left from 0 to 100px over 300 ms at 60 fps means:
- ~18 frames × layout recalculation for the element and its neighbors
- Each layout can cascade — if a sibling's size depends on this element, it reflows too
- After layout, the browser must repaint the affected region
/* SLOW — triggers layout every frame */
.bad {
position: relative;
transition: left 300ms ease;
}
.bad:hover {
left: 100px;
}
/* FAST — compositor only */
.good {
transition: transform 300ms ease;
}
.good:hover {
transform: translateX(100px);
}
Both look identical to the user, but the second version is orders of magnitude cheaper.
3. Compositor-only properties
Only two CSS properties can be animated entirely on the compositor thread (off the main thread):
| Property | Typical use |
|---|---|
transform | Move, scale, rotate, skew |
opacity | Fade in/out |
The compositor takes a snapshot (texture) of the element and manipulates it on the GPU. No layout recalculation, no pixel repainting — just matrix math on a cached bitmap.
4. will-change — hinting the browser
.card {
will-change: transform;
}
will-change tells the browser: "I plan to animate this property soon — go ahead and promote this element to its own compositor layer now."
When to use it
- On elements you will animate (hover cards, slide-in panels)
- Applied before the animation starts (not inside the
:hoverrule — that's too late)
When NOT to use it
| Anti-pattern | Why it's bad |
|---|---|
* { will-change: transform; } | Creates hundreds of layers → huge GPU memory |
Permanent will-change on static elements | Wastes resources for no benefit |
Adding will-change to fix jank you don't understand | Hides the real problem |
Rule: apply
will-changeonly where you have measured jank, and remove it after the animation ends if the element is static most of the time.
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('transitionend', () => {
element.style.willChange = 'auto';
});
5. GPU layers and memory
Each promoted layer consumes GPU texture memory (roughly width × height × 4 bytes). A full-screen layer on a 1920×1080 display ≈ 8 MB. On a 4K screen ≈ 33 MB.
Layers panel (DevTools)
┌──────────────────────────────┐
│ Document (root layer) │
│ ┌────────────┐ │
│ │ .card (own layer) │
│ │ will-change: transform │
│ └────────────┘ │
│ ┌────────────┐ │
│ │ .modal (own layer) │
│ │ transform: translateZ(0) │
│ └────────────┘ │
└──────────────────────────────┘
Too many layers → GPU memory pressure → actual slower performance. This is the ironic cost of premature optimization.
6. Frame budget — the 16.67 ms rule
60 fps = 1000ms / 60 = ~16.67 ms per frame
┌──── 16.67 ms ────┐
│ JS │ Style │ Layout │ Paint │ Composite │
└────────────────────┘
If this exceeds 16.67 ms → dropped frame → visible jank
Your animation code (JS callbacks, style recalculations) must finish within this budget. Compositor-only animations help because transform/opacity changes don't consume main-thread time.
7. Paint flashing in DevTools
How to find expensive paints:
- Open DevTools → Rendering tab (three-dot menu → More Tools → Rendering)
- Enable Paint flashing — green rectangles appear over repainted areas
- Trigger your animation and watch:
- Small green flashes = targeted repaints (usually fine)
- Full-screen green flash every frame = expensive, investigate
Layers panel
DevTools → Layers tab shows which elements are promoted to GPU layers and their memory cost.
8. When NOT to animate
Accessibility
Some users have vestibular disorders — motion triggers nausea, dizziness, or disorientation.
@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;
}
}
Battery and thermal
Infinite animations on mobile drain battery and may cause thermal throttling (CPU/GPU slow down under heat). Pause off-screen animations:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entry.target.style.animationPlayState =
entry.isIntersecting ? 'running' : 'paused';
});
});
observer.observe(document.querySelector('.animated-hero'));
Janky animations are worse than no animation
If you can't hit 60 fps, it's better to snap instantly than show stuttering motion. A janky transition erodes perceived quality more than no transition at all.
9. Practical performance checklist
| Rule | Action |
|---|---|
Animate transform and opacity only | Convert top/left → translateX/Y; convert width/height → scale |
Avoid animating box-shadow | Use a pseudo-element with a pre-rendered shadow and animate its opacity |
Use will-change sparingly | Only on elements you will animate; remove after |
| Test on low-end devices | Chrome DevTools → Performance → CPU throttle 4× |
Respect prefers-reduced-motion | Global reset or per-component fallback |
| Pause off-screen animations | IntersectionObserver → animation-play-state: paused |
| Measure, don't guess | DevTools Performance panel, Lighthouse, paint flashing |
10. Key takeaways
transform+opacity= compositor-only = cheapest animations.- Animating
width,height,top,leftforces layout every frame — avoid it. will-changepromotes elements to GPU layers — use it intentionally, not globally.- 16.67 ms is your frame budget at 60 fps.
prefers-reduced-motionis not optional — it's a baseline accessibility requirement.- A janky animation is worse than no animation — measure before shipping.
Explain-It Challenge
Explain without notes:
- Why animating
transform: translateX(100px)is faster than animatingleft: 100px— trace both through the rendering pipeline. - When
will-changecan actually make performance worse instead of better. - How you would use DevTools to diagnose a hover animation that causes visible frame drops.
Navigation: ← 1.12.d — Keyframe Animations · 1.12 Overview →