Home / Blog / CSS Animation Performance: Only Animate transform & opacity
CSS Animations 2026-09-23 · 10 min read

CSS Animation Performance: Only Animate transform & opacity

Write high-performance CSS animations by limiting changes to transform and opacity. Learn about GPU compositing, reflow, repaint, and will-change.

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:

  1. Style — Calculate which CSS rules apply to which elements.
  2. Layout (Reflow) — Calculate the size and position of every element in the document.
  3. Paint — Fill in pixels — colors, borders, shadows, text.
  4. 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-height
  • top, left, right, bottom (on positioned elements)
  • margin, padding, border-width
  • font-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-color
  • box-shadow, text-shadow
  • border-radius, outline
  • visibility

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 transform and opacity only
  • ✅ Use will-change sparingly on elements that will animate
  • ✅ Avoid animating layout-triggering properties
  • ✅ Use contain: layout for 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-change to 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.

Frequently Asked Questions

Why should I only animate transform and opacity?
These two properties are handled exclusively by the GPU compositor, completely bypassing the layout and paint stages of the rendering pipeline. This means they run on a separate thread and won't cause jank even if JavaScript is busy. All other animatable properties trigger at least a repaint, and many trigger a full layout recalculation on every frame.
What is GPU compositing in the context of CSS?
GPU compositing is the final stage of the browser rendering pipeline where the GPU assembles pre-painted layers into the final image. When an element is promoted to its own compositing layer (a "paint layer"), the GPU can apply transforms and opacity changes to it without repainting — this is what makes transform and opacity animations so fast.
Is will-change always good to use?
No. will-change promotes an element to a compositor layer ahead of time, which reserves GPU memory. Applying it everywhere wastes memory and can actually hurt performance. Use it only on specific elements right before they animate, and consider removing it after the animation is done using JavaScript.
How can I simulate a box-shadow change without causing a repaint?
Create a pseudo-element (::after) with the target box-shadow already applied at full size, but with opacity: 0. Then animate only the opacity to 1 on hover. Since opacity is compositor-only, the shadow appears to animate without triggering any repaint.
What is jank and how do I measure it?
Jank is the visible stutter or choppiness that occurs when the browser drops frames — when rendering a frame takes longer than ~16ms (for 60fps). Measure it using Chrome DevTools' Performance panel: record your animation and look for long frames in the flame chart. Aim for all frames to be under 16ms (green bars).

Try Our Free HTML CSS Tools

Practice what you've learned with our free online tools — no installation required.

Live HTML/CSS Editor CSS Formatter CSS Minifier