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 (transform and opacity) 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 animateStages triggeredCost
width, height, margin, padding, top, leftLayout → Paint → CompositeExpensive
color, background-color, box-shadow, borderPaint → CompositeModerate
transform, opacityComposite onlyCheap

The golden rule: if you can express a visual change with transform or opacity, 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):

PropertyTypical use
transformMove, scale, rotate, skew
opacityFade 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 :hover rule — that's too late)

When NOT to use it

Anti-patternWhy it's bad
* { will-change: transform; }Creates hundreds of layers → huge GPU memory
Permanent will-change on static elementsWastes resources for no benefit
Adding will-change to fix jank you don't understandHides the real problem

Rule: apply will-change only 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:

  1. Open DevTools → Rendering tab (three-dot menu → More Tools → Rendering)
  2. Enable Paint flashing — green rectangles appear over repainted areas
  3. 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

RuleAction
Animate transform and opacity onlyConvert top/lefttranslateX/Y; convert width/heightscale
Avoid animating box-shadowUse a pseudo-element with a pre-rendered shadow and animate its opacity
Use will-change sparinglyOnly on elements you will animate; remove after
Test on low-end devicesChrome DevTools → Performance → CPU throttle 4×
Respect prefers-reduced-motionGlobal reset or per-component fallback
Pause off-screen animationsIntersectionObserveranimation-play-state: paused
Measure, don't guessDevTools Performance panel, Lighthouse, paint flashing

10. Key takeaways

  1. transform + opacity = compositor-only = cheapest animations.
  2. Animating width, height, top, left forces layout every frame — avoid it.
  3. will-change promotes elements to GPU layers — use it intentionally, not globally.
  4. 16.67 ms is your frame budget at 60 fps.
  5. prefers-reduced-motion is not optional — it's a baseline accessibility requirement.
  6. A janky animation is worse than no animation — measure before shipping.

Explain-It Challenge

Explain without notes:

  1. Why animating transform: translateX(100px) is faster than animating left: 100px — trace both through the rendering pipeline.
  2. When will-change can actually make performance worse instead of better.
  3. How you would use DevTools to diagnose a hover animation that causes visible frame drops.

Navigation: ← 1.12.d — Keyframe Animations · 1.12 Overview →