Back

Your animation is layout thrashing and you don't know it

Not all CSS properties are equal. Some animations run on the GPU at 60fps with zero layout cost. Others force the browser to recalculate the entire page every single frame.

202611 min read

You write a hover animation that moves a card up by 4 pixels. On your M-series MacBook it looks butter smooth. You ship it. Then someone on a 2019 Windows laptop opens the page and the whole thing stutters like a slideshow. Same code, same browser, wildly different result.

The instinct is to blame the hardware. Slower machine, worse performance. But that's not really what's going on. The problem is that your animation is triggering a layout recalculation on every frame, and the slower machine doesn't have the CPU headroom to absorb it. On your MacBook, it janks too. You just can't see it because the machine brute-forces through it.

The browser rendering pipeline has stages. Understanding which stage your animation triggers is the difference between smooth and janky, and it has nothing to do with how fast you write your code.

The rendering pipeline

Every frame the browser paints goes through up to five stages. The trick is that not every change triggers every stage. Some changes skip straight to the cheap stages. Others force the browser to start over from the most expensive one.

Browser rendering pipeline

JS / Style

Runs

Layout

Runs

Paint

Runs

Composite

Runs

Animating top/left triggers all 4 stages every frame

Here's what each stage does:

JavaScript/Style: Calculate which CSS rules apply to which elements. This runs any time a class changes, a media query fires, or JS modifies styles.

Layout: Figure out where every element goes on the page. Size, position, margins, all of it. This is the expensive one. Changing width, height, top, left, padding, or margin triggers layout on the element and potentially every sibling and parent around it.

Paint: Fill in the pixels. Backgrounds, borders, text, shadows. Changing color, background, box-shadow, or border-color triggers paint but skips layout.

Composite: Take the painted layers and combine them on the GPU. Changing transform or opacity only triggers this stage. The browser doesn't need to recalculate layout or repaint anything. It just moves the pre-painted texture on the graphics card. This is why these properties are fast.

The rule is simple: If you can express your animation using only transform and opacity, do it. Everything else is more expensive, sometimes drastically so. A translateY(-4px) is free. A top: -4px can tank your frame rate.

See the difference in real time

Both of these animations move a box from left to right. One uses left (triggers layout). The other uses transform: translateX (composited). Hit play and watch the simulated frame cost. On your device the difference might be subtle. On a budget phone, it's the difference between smooth and broken.

left vs transform: translateX

left
Layout + Paint + Composite
L+P+C
--ms/frame
translateX
Composite only
C
--ms/frame

Cost is simulated based on pipeline stages triggered per frame

The left animation triggers layout recalculation on every frame. The browser has to figure out where the element is, repaint it, then composite. The translateX version skips layout and paint entirely. The GPU just moves a texture that was already painted.

In DevTools, you can see this with the "Rendering" panel. Enable "Paint flashing" and you'll see the left animation flash green on every frame (it's repainting). The transform animation won't flash at all.

The compositing layer trap

So if composited properties are fast, why not just slap will-change: transform on everything and call it a day? Because every composited element creates a new GPU layer, and GPU layers cost VRAM. Each layer is basically a bitmap the size of the element, stored on the graphics card.

On a page with 10 composited elements, that's fine. On a page with 200, you can blow through hundreds of megabytes of GPU memory and actually make performance worse. This is called layer explosion.

Layer count vs VRAM usage

Layer 1
Layer 2
Layer 3
Layer 4
4
4 layers × 234KB = 938KB VRAM Comfortable. This is a reasonable number of composited layers.

Each layer = a GPU texture. More layers = more VRAM consumed.

CSS
/*
 * GOOD: promote only what you animate.
 * Set will-change right before the animation,
 * remove it after.
 */

.card {
  transition: transform 0.2s, opacity 0.2s;
}

.card:hover {
  will-change: transform, opacity;
  transform: translateY(-4px);
}

/*
 * BAD: promoting everything.
 * Each of these creates a separate GPU layer.
 */

* {
  will-change: transform; /* don't do this */
}

The CSS property cost sheet

Not all properties are documented equally. Here's a practical reference for the most common animated properties, showing which pipeline stages they trigger and whether they can be composited.

Animated property cost audit

transformGPUComposite only
opacityGPUComposite only
filterGPUComposite only
background-colorPaintPaint + Composite
colorPaintPaint + Composite
box-shadowPaintPaint + Composite
border-colorPaintPaint + Composite
width / heightLayoutLayout + Paint + Comp
top / leftLayoutLayout + Paint + Comp
padding / marginLayoutLayout + Paint + Comp
font-sizeLayoutLayout + Paint + Comp
border-widthLayoutLayout + Paint + Comp

GPU-only properties skip layout and paint entirely


The practical playbook

1. Animate only transform and opacity when possible

This is the single most important thing. Need to move something? Use translateX/Y, not left/top. Need to show/hide? Use opacity, not display or visibility. Need to resize? Use scale() instead of width/height if the content doesn't need to reflow.

2. Use will-change sparingly and temporarily

Add will-change just before an animation starts (on hover, on scroll-into-view) and remove it after. Leaving it on permanently wastes GPU memory. Some frameworks like Motion handle this automatically. If you're doing it manually, consider a CSS class you toggle.

3. Check your layer count

In Chrome DevTools, go to "Layers" panel (you might need to enable it in More Tools). If you see dozens or hundreds of compositing layers, something's wrong. Common culprits: will-change on list items, position: fixed on too many elements, or a library that promotes everything by default.

4. The contain property is your friend

contain: layout tells the browser that changes inside an element won't affect anything outside it. This lets the browser skip a lot of work during layout recalculation. Pair it with content-visibility: auto for off-screen elements and you can dramatically reduce the layout cost of large pages.

CSS
/* Pattern: cheap hover lift */
.card {
  transition: transform 0.2s ease-out,
               box-shadow 0.2s ease-out;
}
.card:hover {
  transform: translateY(-2px);
  /* box-shadow triggers paint, not layout.
     acceptable for hover states. */
}

/* Pattern: off-screen optimization */
.feed-item {
  contain: layout style paint;
  content-visibility: auto;
  contain-intrinsic-size: 0 200px;
}

/* Pattern: animate height without layout
   Use grid trick instead of animating height */
.collapsible {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.3s;
}
.collapsible.open {
  grid-template-rows: 1fr;
}
.collapsible > div {
  overflow: hidden;
}

Expensive

Animating width, height, top, left, padding, margin, font-size. Using will-change on everything. Animating box-shadow on dozens of elements simultaneously.

Cheap

Animating transform and opacity. Using contain to isolate layout. Promoting layers only during active animation. Using the grid-template-rows trick for height animations.

Thinking in layers

Most frontend developers treat CSS animations as a styling concern. Pick a property, add a transition, ship it. But the moment you understand the rendering pipeline, animations become a performance concern. Every property you animate is a choice about which pipeline stages you're willing to pay for, 60 times a second.

The good news is that the fast path covers almost everything you'd want to do. Movement is translate. Scaling is scale. Fading is opacity. Rotation is rotate. That's 90% of UI animation right there, all running on the GPU for free.

The remaining 10% is where craft comes in. Animating box-shadow on hover? That's paint, but it's a single element on a user-triggered event. Totally fine. Animating height on a collapsible section? Use the grid-template-rows trick. Animating background-color? It's paint-only, acceptable in most cases.

The problems start when you animate layout properties on many elements at once, or when you promote too many layers and starve the GPU. Neither of those is hard to avoid once you know what to look for. Open the Layers panel, enable Paint flashing, and profile once before you ship. That's all it takes.