Home / Articles / Animation & Motion /

animation

GPU layers and will-change for transitions

Understanding why some transitions are silky smooth and others are janky — and exactly what will-change does and doesn't fix.

The browser rendering pipeline

Before you can reason about GPU layers, you need a mental model of how the browser turns CSS into pixels. Every frame goes through up to five stages:

  • Style: Compute which CSS rules apply to which elements.
  • Layout: Calculate the position and size of every element in the document flow. This is the most expensive stage — changing one element's width can ripple through the entire page.
  • Paint: Record drawing instructions (fills, borders, text) for each layer into a paint record. The browser doesn't draw pixels here — it records what to draw.
  • Composite: Send the painted layers to the GPU and combine them into the final screen image. This stage happens on a separate compositor thread, completely independent of JavaScript.

The key insight: only the compositor stage runs off the main thread. Layout and paint block JavaScript. If your transition triggers layout or paint on every frame, it's competing with JavaScript for the main thread's time — and that's where jank comes from.

Aiming for 60 fps means each frame has 16.6 ms to complete. Layout and paint together can easily burn through that budget on a complex page.

Compositor-only properties

Certain CSS properties can be animated entirely on the compositor thread, skipping layout and paint entirely. The browser can hand these off to the GPU and change them frame-by-frame without touching the main thread at all.

The two properties that are always compositor-only: transform and opacity. These are the gold standard for animation. Anything else is at least partially on the main thread.

/* Compositor-only — runs off main thread */
.card {
  transition: transform 0.25s ease, opacity 0.25s ease;
}
.card:hover {
  transform: translateY(-4px);
  opacity: 0.9;
}

/* Triggers paint every frame — avoid for animations */
.card {
  transition: box-shadow 0.25s ease;
}
.card:hover {
  box-shadow: 0 8px 32px oklch(0% 0 0 / 0.3);
}

filter is also compositor-friendly in most cases, though complex filters can force a repaint. background-color, color, border-color, and box-shadow all trigger paint. width, height, top, left, and any property that affects layout trigger both layout and paint — the worst case.

What a GPU layer actually is

The browser divides the page into layers — rectangular regions that are painted independently and then composited together. Think of them like transparent acetate sheets stacked on top of each other. When you animate a layer, the browser only needs to move or transform that sheet; it doesn't have to repaint anything beneath it.

Layers are expensive to create and to keep in GPU memory. Each layer requires its own backing store — a texture uploaded to the GPU. On a resource-constrained device, layer explosion (hundreds of promoted layers) can cause more problems than it solves: increased memory usage, texture upload time, and compositor overhead that can slow down frame rendering below 60 fps.

The browser automatically promotes elements to their own layer when it detects they need to be composited independently — for example, when you apply transform or opacity transitions. You can also hint that promotion should happen in advance.

GPU layers are not free. Each one consumes GPU memory proportional to its dimensions. Promoting every animated element "just in case" is a common source of memory pressure on mobile.

will-change: what it actually does

will-change tells the browser that an element's specified properties are likely to change, so it should optimize ahead of time. For transform and opacity, this means promoting the element to its own GPU layer before the animation begins.

.modal {
  will-change: transform, opacity;
  transform: translateY(20px);
  opacity: 0;
  transition: transform 0.3s ease, opacity 0.3s ease;
}

.modal.is-open {
  transform: translateY(0);
  opacity: 1;
}

Without will-change, the browser promotes the element to a layer at the moment the transition starts. There can be a brief stutter as the texture is uploaded to the GPU. With will-change, the texture is already there — the first frame of the animation is as smooth as the last.

The practical difference is most noticeable in two scenarios: elements that animate immediately on page load (no time for the browser to optimize), and elements where the first frame of motion is the most perceptible (slide-in modals, dropdown menus).

The translateZ(0) hack — and why will-change replaced it

Before will-change was widely supported, developers used a trick: apply a no-op 3D transform to force layer promotion.

/* The old hack — do not use in new code */
.element {
  transform: translateZ(0);
  /* also seen as: */
  transform: translate3d(0, 0, 0);
  backface-visibility: hidden; /* often paired with it */
}

This worked because browsers treat 3D-transformed elements as needing their own compositing layer. The hack is still functional but has two problems: it applies a transform to the element's coordinate space (which can interfere with other transforms), and it gives the browser no information about why you're promoting the layer, making it harder to optimize intelligently.

will-change is the correct modern replacement. It's purely a hint — it doesn't change the element's rendering or layout, just instructs the browser to prepare.

Remove transform: translateZ(0) from your codebase wherever you find it. Replace with will-change: transform where the hint is genuinely needed, or remove it entirely if the element is already composite-accelerated by virtue of animating transform.

When to actually use will-change

The spec is explicit: will-change is intended as a last resort for known performance problems, not a general-purpose optimization to apply everywhere. Here's the decision framework:

  • Use it when an element animates immediately on interaction and the first frame stutters. Modal open animations, menu reveals, and page transitions are candidates.
  • Use it temporarily — ideally applied via JavaScript just before the animation starts, removed immediately after. This avoids the memory overhead of always-promoted layers.
  • Don't use it on elements that only animate rarely (the browser will optimize them fine) or on large background elements (the texture cost is too high).
  • Don't use it on every animated element as a blanket rule. The browser is already smart enough to promote elements as needed when transform or opacity transitions run.
/* Apply just before, remove just after */
const btn = document.querySelector('.menu-trigger');
const menu = document.querySelector('.menu');

btn.addEventListener('click', () => {
  menu.style.willChange = 'transform, opacity';
  menu.classList.add('is-open');
});

menu.addEventListener('transitionend', () => {
  menu.style.willChange = 'auto';
});

will-change: auto

will-change: auto is the default value and means "no hints" — the browser does whatever it normally does. Setting it explicitly resets any previous hint, which is why it's useful in the transitionend cleanup pattern above.

There's no benefit to writing will-change: auto in a stylesheet that hasn't set any other will-change value — it's a no-op in that context. Its value is entirely as a reset value in JavaScript.

Layer explosion: the real cost of over-promotion

Consider a page with a card grid where each card has will-change: transform set unconditionally in CSS. If there are 50 cards and each is 400×300 px on a 2× retina display, each card's texture is 800×600 pixels = ~1.8 MB of GPU memory. 50 cards = ~90 MB just for card textures, before the rest of the page's content.

Mobile devices often have 512 MB to 2 GB of total memory shared between CPU and GPU. Running out of GPU texture memory causes the browser to evict layers and re-upload them — exactly the kind of jank you were trying to prevent.

/* Don't do this — promotes every card unconditionally */
.card {
  will-change: transform;
}

/* Better — only promote on hover, where the animation is imminent */
.card:hover {
  will-change: transform;
}

/* Or use JS to apply it just before the animation triggers */

contain: layout paint as a lightweight alternative

CSS containment (contain) lets you tell the browser that an element's subtree is independent from the rest of the page. contain: layout paint on a container means changes inside it can't affect layout or paint outside it — the browser can skip large portions of the rendering pipeline when something inside changes.

.card-grid {
  contain: layout paint;
}

/* contain: strict is shorthand for layout paint style size */
.isolated-widget {
  contain: strict;
}

Containment doesn't promote elements to GPU layers, but it dramatically reduces the scope of layout and paint invalidation. For a card grid where hovering one card might otherwise trigger a layout check across the entire document, contain: layout paint on the grid container can measurably improve performance without the memory cost of layer promotion.

contain: layout paint is a better first reach than will-change for most scenarios. Reach for will-change only when you've profiled and confirmed that layer promotion is the bottleneck.

Debugging layers in DevTools

Chrome DevTools has two tools for investigating GPU layers:

  • Layers panel (More tools → Layers): Shows a 3D visualization of every compositing layer on the page, with memory usage and the reason each layer was created. Look for "Will-change: transform" or "Has a 3D transform" in the details panel.
  • Rendering tab (More tools → Rendering → Layer borders): Overlays colored borders on the page — orange for compositing layers, blue for paint layers. A sea of orange usually indicates layer explosion.

The Performance panel's flame chart shows "Composite Layers" events. If compositing is taking a disproportionate amount of frame time, you have too many layers. If layout or paint events are tall, you have the opposite problem — properties that should be compositor-only are triggering expensive work.

The practical checklist

When you're building a transition and want to ensure it's smooth:

  • Prefer animating transform and opacity. Use transform tricks (translate instead of top/left, scale instead of width/height) to simulate layout changes without triggering layout.
  • Profile first, optimize second. Open DevTools Performance, record the interaction, and look for dropped frames before adding any will-change hints.
  • Apply will-change temporarily via JavaScript, not statically in CSS, wherever possible.
  • Check GPU memory usage in the Layers panel after applying will-change — the win in first-frame smoothness must outweigh the memory cost.
  • Use contain: layout paint on containers to limit the scope of invalidation before reaching for layer promotion.
  • Always respect prefers-reduced-motion — a user who disables animations doesn't benefit from any of these optimizations.
@media (prefers-reduced-motion: no-preference) {
  .dialog {
    transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
                opacity 0.3s ease;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dialog {
    transition: opacity 0.15s ease;
  }
}