Why CSS Animation Performance Matters
The gold standard for smooth UI is 60fps — 60 frames per second. That means the browser has roughly 16.67 milliseconds to prepare and render each frame. When an animation forces expensive operations on every frame, the browser misses that budget, causing dropped frames and visible jank — the choppy, stuttering motion that instantly makes an interface feel cheap and broken.
The good news: CSS animations can be silky smooth with one simple rule — only animate transform and opacity. Understanding why requires a brief look at how the browser renders a page.
The Browser Rendering Pipeline
Every time a visual property changes, the browser must go through some or all of these stages:
- Style — Calculate which CSS rules apply to which elements.
- Layout (Reflow) — Calculate the size and position of every element in the document.
- Paint — Fill in pixels — colors, borders, shadows, text.
- Composite — Assemble the painted layers and send them to the screen via the GPU.
Steps 2 and 3 are expensive. Step 4 is cheap. The goal of performant animation is to only trigger compositing, skipping layout and paint entirely.
What Triggers Reflow (Layout)?
A reflow recalculates the geometry of the entire document (or a large portion of it). Animating any of these properties causes a reflow on every frame:
width,height,min-width,max-heighttop,left,right,bottom(on positioned elements)margin,padding,border-widthfont-size,line-height
What Triggers Repaint?
A repaint redraws pixels without recalculating geometry — cheaper than reflow but still significant at 60fps. Animating these causes repaint:
color,background-colorbox-shadow,text-shadowborder-radius,outlinevisibility
What Only Triggers Compositing?
Only two properties bypass layout and paint entirely and are handled directly by the GPU compositor:
transform(translate, scale, rotate, skew)opacity
These operations happen on the GPU thread, completely separate from the main JavaScript thread. Even if your JS is blocked or busy, compositor-only animations keep running smoothly at 60fps.
The Right Way vs. The Wrong Way
Moving an Element: Wrong vs Right
/* ❌ BAD: animating 'left' triggers layout on every frame */
@keyframes moveLeft {
from { left: 0; }
to { left: 300px; }
}
.box { position: absolute; animation: moveLeft 1s ease infinite; }
/* ✅ GOOD: animating 'transform' only triggers compositing */
@keyframes moveRight {
from { transform: translateX(0); }
to { transform: translateX(300px); }
}
.box { animation: moveRight 1s ease infinite; }
Showing/Hiding: Wrong vs Right
/* ❌ BAD: animating background-color triggers repaint */
@keyframes flash {
from { background-color: white; }
to { background-color: yellow; }
}
/* ✅ GOOD: use opacity for fade effects */
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
Scaling: Wrong vs Right
/* ❌ BAD: animating width/height triggers layout */
@keyframes grow {
from { width: 100px; height: 100px; }
to { width: 200px; height: 200px; }
}
/* ✅ GOOD: use transform: scale() */
@keyframes grow {
from { transform: scale(1); }
to { transform: scale(2); }
}
GPU Compositing and Paint Layers
When the browser knows an element will be animated, it can promote it to its own paint layer (also called a compositing layer or GPU layer). This layer is uploaded to the GPU as a texture once, and subsequent animation frames just move or transform that texture — no re-painting required.
Browsers automatically promote elements to their own layer when they use CSS animations on transform or opacity. You can also hint the browser ahead of time using will-change.
The will-change Property
will-change tells the browser which properties are about to change, giving it time to optimize beforehand — typically by promoting the element to a compositor layer early.
/* Tell the browser this element will be animated */
.animated-card {
will-change: transform, opacity;
}
/* For hover states, apply will-change on the parent */
.card-wrapper:hover .card {
will-change: transform;
}
Warning: Do NOT apply will-change to everything. Each compositing layer consumes GPU memory. Overusing it can make performance worse. Apply it only to elements that will actually animate, and ideally remove it after the animation ends.
// Good pattern: add will-change before animation, remove after
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('animationend', () => {
element.style.willChange = 'auto';
});
Detecting Layer Promotion in DevTools
Chrome DevTools provides two key tools for diagnosing animation performance:
- Layers panel (More Tools → Layers) — Visualize which elements are on their own compositing layer. Yellow borders indicate layer boundaries.
- Performance panel — Record an animation and inspect the frame timeline. Look for long "Layout" and "Paint" tasks; frame bars should stay green (under 16ms).
- Rendering settings — Enable "Paint flashing" to see green overlays on areas that repaint. If your animation area flashes every frame, you have a repaint problem.
Real-World Performance Patterns
Performant Loading Spinner
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
/* will-change is fine here — single dedicated element */
will-change: transform;
animation: spin 0.75s linear infinite;
}
Performant Card Lift on Hover
.card {
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
/* Avoid: box-shadow transition causes repaint */
}
.card:hover {
transform: translateY(-4px) scale(1.02);
/* Simulate shadow lift with a pseudo-element instead of box-shadow */
}
/* Trick: animate a pseudo-element's opacity for a shadow lift effect */
.card::after {
content: '';
position: absolute;
inset: 8px;
border-radius: inherit;
box-shadow: 0 12px 32px rgba(0,0,0,0.15);
opacity: 0;
transition: opacity 0.2s ease-out;
z-index: -1;
}
.card:hover::after {
opacity: 1;
}
Staggered List Animation (Compositor-Safe)
@keyframes enterUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.list-item {
animation: enterUp 0.4s ease-out both;
}
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 60ms; }
.list-item:nth-child(3) { animation-delay: 120ms; }
.list-item:nth-child(4) { animation-delay: 180ms; }
The contain Property
For complex UIs, contain: layout or contain: strict can limit the scope of reflows. If an element is self-contained and its size changes don't affect the surrounding layout, contain prevents those reflows from propagating up the tree:
.widget {
/* Changes inside .widget won't trigger layout on siblings */
contain: layout style;
}
Reducing Animation Jank: Checklist
- ✅ Animate
transformandopacityonly - ✅ Use
will-changesparingly on elements that will animate - ✅ Avoid animating layout-triggering properties
- ✅ Use
contain: layoutfor isolated components - ✅ Prefer CSS animations over JS
setInterval - ✅ Test on low-end devices and throttled CPU in DevTools
- ✅ Respect
prefers-reduced-motion - ❌ Don't apply
will-changeto every element - ❌ Don't animate
width,height,top,left - ❌ Don't run dozens of simultaneous animations
You can test all of these patterns right in the Live HTML/CSS/JS Editor — open DevTools alongside it to measure real frame rates while you iterate.