The Rendering Pipeline

Before optimising animations, you need to understand how the browser renders frames. Every frame goes through up to four stages: Layout (calculating positions and sizes), Paint (filling pixels), Composite (assembling layers). Not every property change triggers all stages — and that is the key to smooth animations.

  • Layout (reflow) — most expensive. Triggered by any property that affects element size or position. Examples: width, height, margin, padding, top, left, font-size.
  • Paint — moderately expensive. Triggered by visual property changes that do not affect layout. Examples: background-color, box-shadow, border-color, outline.
  • Composite — cheapest. Triggered only by properties that can be handled entirely on the GPU without touching the main thread. Examples: transform, opacity, filter (some values).
🔑 The rule

Only animate transform and opacity if you care about performance. Everything else — width, height, top, left, margin, padding, background-color, box-shadow — triggers layout or paint on every frame, which will cause jank on complex pages or low-end devices.

Cheap vs Expensive Properties

PropertyStage triggeredAnimate?
transform (translate, scale, rotate)Composite✅ Yes
opacityComposite✅ Yes
filter (blur, brightness, etc.)Composite (GPU)⚠️ Carefully
clip-pathPaint⚠️ With will-change
background-colorPaint⚠️ Simple cases only
box-shadowPaint⚠️ Use pseudo-element trick
width / heightLayout + Paint❌ Avoid
top / leftLayout + Paint❌ Use transform instead
margin / paddingLayout + Paint❌ Avoid
font-sizeLayout + Paint❌ Avoid

Transform and Opacity: The Golden Pair

Most UI animations can be built entirely with transform and opacity. Moving an element? Use translateX() or translateY() instead of animating left/top. Scaling? Use scale() instead of animating width/height. Showing/hiding? Fade with opacity.

CSS
/* ❌ Expensive — triggers layout */
@keyframes slide-bad {
  from { left: -100px; }
  to   { left: 0; }
}

/* ✅ Cheap — compositor only */
@keyframes slide-good {
  from { transform: translateX(-100px); }
  to   { transform: translateX(0); }
}

/* ❌ Expensive — triggers paint */
@keyframes grow-bad {
  from { width: 0; }
  to   { width: 300px; }
}

/* ✅ Cheap — compositor only */
@keyframes grow-good {
  from { transform: scaleX(0); transform-origin: left; }
  to   { transform: scaleX(1); }
}

/* Entrance animation — the most common pattern */
@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
.card { animation: fade-up 0.4s ease both; }

Using will-change Correctly

The will-change property tells the browser to promote an element to its own compositing layer before the animation starts. This eliminates the promotion cost from the first frame of the animation. However, it uses GPU memory — use it sparingly, only on elements that actually animate, and remove it after the animation completes.

CSS
/* ✅ Correct usage — on elements that animate */
.animated-card {
  will-change: transform, opacity;
}

/* ✅ Apply on hover, before animation starts */
.card:hover {
  will-change: transform;
}

/* ❌ Wrong — too broad, wastes GPU memory */
* { will-change: transform; }
body { will-change: everything; }

/* ✅ Remove after animation (via JS) */
element.addEventListener('animationend', () => {
  element.style.willChange = 'auto';
});

Common Mistakes

Animating box-shadow directly

Box-shadow changes trigger a repaint on every frame. The solution is to pre-render the target shadow on a pseudo-element and animate its opacity instead:

CSS
/* ❌ Triggers repaint every frame */
.card { transition: box-shadow 0.3s; }
.card:hover { box-shadow: 0 20px 40px rgba(0,0,0,0.2); }

/* ✅ Animates opacity — compositor only */
.card { position: relative; }
.card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 20px 40px rgba(0,0,0,0.2);
  opacity: 0;
  transition: opacity 0.3s ease;
}
.card:hover::after { opacity: 1; }

Not using transform-origin correctly

Scale and rotation animations use transform-origin as the anchor point. By default it is the element's centre. For button press effects you often want the origin to be center, but for expand-from-left effects you want left:

CSS
/* Scale from the bottom — like a button press */
.btn:active {
  transform: scale(0.97);
  transform-origin: center;
}

/* Progress bar — grows from left */
.progress-bar {
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 0.4s ease;
}
.progress-bar.loaded {
  transform: scaleX(1);
}

Measuring Animation Performance

The best way to confirm your animation is running on the compositor is Chrome DevTools. Open DevTools → Rendering panel → enable "Layer borders" to see composited layers highlighted in blue. Enable "Frame Rendering Stats" to see the frames-per-second counter in the top corner of the page as your animation runs. If it stays at 60fps, you are on the compositor. If it drops, something is triggering layout or paint.

CSS
/* Smooth 60fps entrance animation checklist:
   ✓ Uses only transform and opacity
   ✓ animation-fill-mode: both (prevents flash)
   ✓ animation-timing-function: ease-out for entrances
   ✓ will-change only if needed
   ✓ prefers-reduced-motion respected */

@media (prefers-reduced-motion: no-preference) {
  @keyframes fade-up {
    from { opacity: 0; transform: translateY(20px); }
    to   { opacity: 1; transform: translateY(0); }
  }
  .animate-in {
    animation: fade-up 0.4s ease-out both;
  }
}

Build smooth CSS animations on the composited layer

Transform and opacity animations only — the properties that never trigger layout or paint.

Open Animation Generator →