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).
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
| Property | Stage triggered | Animate? |
|---|---|---|
| transform (translate, scale, rotate) | Composite | ✅ Yes |
| opacity | Composite | ✅ Yes |
| filter (blur, brightness, etc.) | Composite (GPU) | ⚠️ Carefully |
| clip-path | Paint | ⚠️ With will-change |
| background-color | Paint | ⚠️ Simple cases only |
| box-shadow | Paint | ⚠️ Use pseudo-element trick |
| width / height | Layout + Paint | ❌ Avoid |
| top / left | Layout + Paint | ❌ Use transform instead |
| margin / padding | Layout + Paint | ❌ Avoid |
| font-size | Layout + 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.
/* ❌ 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.
/* ✅ 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:
/* ❌ 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:
/* 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.
/* 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 →