EmpireUI
Get Pro
← Blog9 min read#animation performance#gpu#will-change

CSS Animation Performance: GPU Compositing, will-change, Layout Thrashing

Stop blaming slow animations on React. Most jank lives in the CSS layer — bad properties, missing compositing hints, and layout thrashing you never noticed.

GPU performance graph with colorful animation frames on dark screen

Why Your Animations Feel Janky (It's Not What You Think)

Most devs chase animation performance by reducing React re-renders, tweaking bundle size, or switching animation libraries. Honestly, that rarely helps. The real culprit is almost always the CSS rendering pipeline itself — specifically which properties you're animating and whether the browser's compositor can handle them without involving the main thread.

Here's the short version: browsers render in three stages — style, layout, and paint — and then a compositor combines the final layers. Animating properties that trigger layout (width, height, top, left, margin) means the browser has to recompute the geometry of *everything* every single frame. At 60fps, that's 16ms to do all of it. Miss that budget once and you see a frame drop.

Animating transform and opacity instead skips layout and paint entirely on modern browsers. The compositor handles them directly on the GPU. That's why a translateX animation at 1000 elements still hits 60fps while a left animation at 10 elements stutters. The property choice is everything.

Worth noting: this isn't new information. Chrome DevTools has had the 'Layers' panel since around 2015, and the will-change property shipped in Chrome 36 back in 2014. We've had the tools to diagnose this for over a decade. Yet layout-thrashing animations are still everywhere in production.

The Compositing Pipeline Explained Without the Handwaving

The browser rendering pipeline has four stages: Style → Layout → Paint → Composite. Each CSS property you animate triggers work starting from a specific stage. Get this table in your head and you'll understand every performance decision that follows.

Composite-only properties (the good ones): transform, opacity, filter (partially), backdrop-filter. These are handed straight to the GPU compositor thread, which runs independently from the main JavaScript thread. Even if your JS is pegging the CPU, a composite-only animation keeps running smoothly.

Paint-triggering properties: color, background-color, border-color, box-shadow, text-shadow. These require a repaint of the affected layer but don't force layout. Still bad, but less catastrophic than layout triggers. Quick aside: box-shadow is one of the most commonly animated properties in UI kits and one of the most expensive — animating it on a card hover across a list of 50 items tanks performance hard.

Layout-triggering properties (the dangerous ones): width, height, padding, margin, top, left, right, bottom, font-size, border-width. These force the browser to recompute layout for the element *and* potentially cascade down to every child and back up to every parent. Chrome DevTools calls these 'layout' in the performance trace. Firefox calls them 'reflow'. Either way, you don't want them happening 60 times per second.

The test is simple: open DevTools, record a performance trace of your animation, and look for purple 'Layout' blocks on the main thread. If they appear every frame, you're doing it wrong. Move to transform equivalents and those blocks disappear.

will-change: The Right Way and the Definitely Wrong Way

The will-change property tells the browser to create a new compositor layer for an element *before* the animation starts. Without it, the browser might create the layer mid-animation, causing a flash or stutter on the first frame. With it, the layer is ready to go.

/* Good — specific and targeted */
.card:hover {
  will-change: transform;
}

.card {
  transition: transform 200ms ease-out;
}

/* Even better — hint before hover so the layer is pre-promoted */
.card-wrapper:hover .card {
  will-change: transform;
}

In practice, will-change: transform on every element in your app is one of the fastest ways to *destroy* performance. Each compositor layer consumes GPU memory. Promote 500 elements and you've allocated hundreds of megabytes of VRAM just sitting there doing nothing. On a mid-range Android phone with shared GPU memory, this causes the browser to evict layers and your animation still stutters — just for a different reason now.

The pattern that actually works: apply will-change on parent hover or on a class you add via JS right before the animation starts, then remove it when the animation ends. This way layers are promoted only when needed.

// Promote the layer before animation, clean up after
function animateCard(el) {
  el.style.willChange = 'transform';
  el.addEventListener('transitionend', () => {
    el.style.willChange = 'auto';
  }, { once: true });
  el.classList.add('is-animating');
}

One more thing — will-change: auto is not the same as removing the property. Setting it to auto explicitly tells the browser to use its default heuristics, which is fine. But if you've set will-change in a CSS rule, you need to override it with auto in the relevant state, not just hope it goes away.

Layout Thrashing: The Invisible Performance Killer

Layout thrashing happens when you mix DOM reads and writes in a loop, forcing the browser to recalculate layout synchronously before it's ready. It's not a CSS-only problem — it's a JS + CSS problem — but it directly tanks animation performance.

The classic example: you're building a staggered animation and you read element.offsetWidth inside a loop that also sets element.style.transform. Every read after a write forces a synchronous layout recalculation. At 20 elements, you're triggering 20 forced layouts in a single frame. Chrome DevTools marks these in red in the performance trace as 'Forced reflow'.

// Thrashing — DON'T do this
elements.forEach(el => {
  const width = el.offsetWidth; // READ forces layout
  el.style.transform = `translateX(${width}px)`; // WRITE
});

// Fixed — batch reads, then writes
const widths = elements.map(el => el.offsetWidth); // All reads first
elements.forEach((el, i) => {
  el.style.transform = `translateX(${widths[i]}px)`; // All writes after
});

If you're doing anything more complex than that batch pattern, reach for requestAnimationFrame. It gives you a proper slot in the browser's render loop so your reads and writes happen at the right time. Or just use the Web Animations API — it handles all this scheduling for you.

Look, the DOM read properties that trigger layout are: offsetWidth, offsetHeight, offsetTop, offsetLeft, scrollTop, scrollLeft, clientWidth, clientHeight, getBoundingClientRect(), and getComputedStyle(). Memorize that list. Any time you call one of those inside an animation loop, you're potentially thrashing.

transform vs top/left: A Concrete Example

Let's make this concrete. You've got a tooltip that animates in from 8px above its target. Here's the version most devs write first:

/* Slow version — triggers layout every frame */
.tooltip {
  position: absolute;
  top: -8px;
  opacity: 0;
  transition: top 200ms ease, opacity 200ms ease;
}

.tooltip.visible {
  top: 0;
  opacity: 1;
}

Every frame of that top transition, the browser runs layout for the tooltip and potentially its containing block. Now the fast version:

/* Fast version — composite only */
.tooltip {
  position: absolute;
  top: 0; /* Fixed position in layout */
  opacity: 0;
  transform: translateY(-8px);
  transition: transform 200ms ease, opacity 200ms ease;
}

.tooltip.visible {
  opacity: 1;
  transform: translateY(0);
}

Same visual result. top is set once and never changes — the browser computes layout for it exactly once. The animation runs entirely through transform and opacity, which are composite-only. The frame timeline in DevTools goes from layout-heavy purple blocks every 16ms to a flat line with just compositor ticks. That's the difference between 40fps and a locked 60fps on a budget device.

This same principle applies to every UI kit you build. If you're building glassmorphism components or card hovers for a design system, this is the swap that makes them feel native rather than sluggish. The glassmorphism generator uses transform-based transitions precisely for this reason.

Measuring First: Chrome DevTools Performance Panel

Don't guess. Profile first. Open Chrome DevTools, go to the Performance tab, click the CPU throttle dropdown and set it to 4x slowdown. This simulates a mid-range Android device — which is likely what a huge chunk of your users are on in 2026. Now record while you trigger your animation.

What you're looking for in the flame chart: purple 'Layout' blocks on the main thread that repeat every frame, green 'Paint' blocks that appear more than once per animation cycle, and yellow 'Scripting' blocks that happen inside requestAnimationFrame callbacks. The 'Layers' panel (⋮ → More tools → Layers) shows you which elements have been promoted to their own compositor layer and how much memory each is consuming.

The 'Rendering' panel (⋮ → More tools → Rendering) has checkboxes for 'Layer borders' (green = composited, orange = not), 'Paint flashing' (red flicker whenever something repaints), and 'FPS meter'. Enable all three during development. Paint flashing on a hover animation you thought was composite-only is a dead giveaway that something's wrong.

One metric worth tracking: Cumulative Layout Shift (CLS). Animations that move elements around and affect document flow contribute to CLS. Composite-only animations using transform don't affect document flow at all, so they contribute 0 to CLS. That's another reason to make the switch — it directly improves your Core Web Vitals score.

For production monitoring, you can use the PerformanceObserver API to track layout-shift entries and log them back to your analytics. Pair that with a gradient generator or design system audit to catch which animated UI components are the worst offenders.

Practical Rules for Animation Performance at Scale

After profiling dozens of component libraries, here's what actually moves the needle. Animate only transform and opacity for motion. If you need to animate color or background, consider whether a pseudo-element trick can keep the main element on a compositor layer while the visual change happens on a cheaper layer below it.

/* Animating background the compositor-friendly way */
.button {
  position: relative;
  isolation: isolate;
}

.button::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--hover-bg);
  opacity: 0;
  transition: opacity 150ms ease;
  z-index: -1;
}

.button:hover::before {
  opacity: 1;
}

Keep will-change scoped and temporary. If you're building reusable components for something like a React component library, expose a prop or CSS custom property that lets consumers opt into layer promotion rather than forcing it on every instance.

Avoid animating filter on large elements if you can help it. filter: blur() in particular forces a repaint of the entire element including its subtree. If you need a blur animation — like for a modal backdrop — animate backdrop-filter on an overlay element rather than filter on the content behind it. Better yet, pre-blur a screenshot and crossfade with opacity. That's what high-end apps do.

That said, there are exceptions. GSAP's will-change handling, for example, is smart about promotion timing — it adds and removes the property automatically around each tween. If you're using GSAP with React, you get this behavior for free. For pure CSS transitions on interactive components like the ones you'd find browsing through Empire UI, stick to the manual approach described above and you'll be fine.

FAQ

Does will-change actually improve animation performance?

Yes, but only when applied sparingly and removed after the animation. Applying it to every element wastes GPU memory and can make performance worse on low-end devices.

Can I animate width and height without hurting performance?

Not really — both trigger layout. Use transform: scale() as a visual substitute when possible, or animate max-height with a known value. It's a tradeoff, not a clean fix.

Why does my transform animation still stutter even though I'm using will-change?

Most likely layout thrashing from a JS read inside the animation loop, or too many promoted layers consuming GPU memory. Profile with DevTools paint flashing enabled to find the real cause.

Is there a difference between CSS transitions and CSS animations for GPU compositing?

No — the compositor doesn't care which syntax you used. Both transition and @keyframes get composited if they only touch transform and opacity.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Animation Performance: GPU Layers, will-change, 60fpsWeb Animations API in 2026: Native Animations Without a LibraryAnimation Performance in React: GPU Layers, will-change and the Right ToolsCSS Houdini Paint Worklet: Custom CSS Properties With GPU Power