will-change and the Compositor Thread: Layer Promotion Without the Footguns
will-change: transform is one of the most cargo-culted declarations in CSS. It is pasted onto elements as a generic "make it fast" charm, and just as often it makes things slower. The narrow problem this guide solves is understanding what the declaration actually asks the engine to do — allocate a separate compositor layer for an element, ahead of time — so that you can judge when that allocation pays for itself and when it is a pure cost. This page sits under Performance & GPU Acceleration. The question of which properties belong in an animation at all is settled in Optimizing CSS Animations for 60fps; this page assumes you have already made that choice and are deciding whether to promote.
What you will understand by the end:
- What a compositor layer is and what one costs in memory
- Everything that promotes an element, with and without
will-change - The timing rule that decides whether the hint does anything
- The stacking-context and containing-block side effects nobody expects
What a layer is, and what it costs
The compositor does not work with elements. It works with layers: rectangular textures that have already been rasterised into GPU memory, each with a transform matrix and an opacity value. Producing a frame means drawing those textures in order with their current matrices. If an animation only changes a matrix or an alpha, the compositor can produce every frame of it without asking the main thread for anything — the textures are already in memory and none of them needs redrawing.
Most of your page lives in a small number of shared layers. Promotion is the act of splitting one element out into a texture of its own. That is what makes a transform animation nearly free: the element's pixels stop being baked into a texture shared with its neighbours, so moving it does not invalidate theirs.
The price is memory, and it is easy to underestimate. A layer holds one 32-bit pixel per device pixel it covers, so its cost is roughly width × height × 4 bytes, multiplied again by the device pixel ratio squared. A full-width hero on a 3× phone screen can be several megabytes on its own. Beyond memory, every extra layer adds per-frame work: the compositor must sort them, clip them, and check them against each other, so a page with hundreds of layers can composite more slowly than the same page with a dozen even when nothing overflows.
What promotes an element, with and without the hint
will-change is not the only route to a layer, and knowing the others stops you from adding a hint the engine had already acted on. Chromium and WebKit promote an element when, among other conditions, it has a 3D transform or translateZ, it is a <video>, <canvas> or plugin surface, it has an in-flight compositor-driven transform or opacity animation, or it overlaps something already promoted. That last condition is the one that produces surprise layer counts: promoting one element can force every element painted above it into its own layer too, because the compositor has to preserve paint order. Ten deliberate promotions can become fifty accidental ones.
The declaration itself accepts a comma-separated list of property names plus two special keywords:
| Value | Meaning |
|---|---|
auto | The initial value: no hint, the browser decides on its own |
transform, opacity | Prepare for changes to that property, which implies a layer |
scroll-position | This element's scroll offset will change; pre-render more of the scrollable content |
contents | The contents will change often; do not cache the painted result aggressively |
| any other property | A generic "this will change" hint, honoured only if the engine has an optimisation for it |
contents is the value most often used wrongly: it tells the browser not to hold onto a rasterised copy, which is the right answer for something repainting constantly and exactly the wrong answer for a static element. And listing a property with no available optimisation — will-change: background-color — costs nothing but achieves nothing either.
Complete working implementation
The point of this file is the scoping of the hint, not the animation. will-change never appears in the base rule; it exists only in the states where a change is genuinely imminent, so no layer outlives the interaction that needed it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scoped will-change</title>
<style>
body {
font: 16px/1.5 system-ui, sans-serif;
display: grid; place-content: center;
min-height: 100vh; margin: 0; background: #eef1f6;
}
.card {
width: 18rem;
padding: 1.25rem;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 0.12);
/* Composited-only animation: transform and opacity, never geometry. */
transition: transform 300ms cubic-bezier(0.25, 1, 0.5, 1),
opacity 300ms ease;
/* NOTE: deliberately NO will-change in the base rule. A hint here
would keep a layer allocated for the element's entire lifetime. */
}
/* The parent is the hover target, so the hint lands one state change
BEFORE the transform does — the pointer entering the parent is the
"imminent" signal, and the browser gets a frame to prepare. */
.card-zone:hover .card { will-change: transform; }
.card:hover,
.card:focus-visible {
transform: translateY(-6px) scale(1.02);
}
/* A padded catcher around the card gives the hint somewhere to fire
from before the card itself is entered. */
.card-zone { padding: 2.5rem; }
/* For a running keyframe animation, scoping the hint to the animating
class is also correct: it lives exactly as long as the animation. */
.card.is-animating {
will-change: transform, opacity;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
50% { transform: scale(1.03); opacity: 0.9; }
}
/* Nothing to composite when motion is off, so do not allocate a layer. */
@media (prefers-reduced-motion: reduce) {
.card-zone:hover .card { will-change: auto; }
.card:hover, .card:focus-visible { transform: none; }
.card.is-animating { will-change: auto; animation: none; }
}
</style>
</head>
<body>
<div class="card-zone">
<article class="card" tabindex="0">
<h3>Promoted on demand</h3>
<p>A layer is created when interaction is imminent, not for the page's life.</p>
</article>
</div>
</body>
</html>
The technique that makes it work
The subtlety is timing, and it is why so many people conclude that will-change "does nothing". The hint only helps if the browser has a frame to act on it before the animation starts. Write will-change: transform and transform: translateY(-6px) in the same rule — .card:hover { will-change: transform; transform: … } — and both arrive in the same style change, so the engine promotes and animates in one go. That is exactly what it would have done without the hint. You have paid for a layer and bought nothing.
The implementation above separates them by one selector. Hovering .card-zone applies the hint; hovering the inner .card a moment later applies the transform. Any structure that creates that gap works: a padded parent, a focus state that precedes activation, or a class you add on pointerenter and remove on transitionend. When no such gap exists — a keyframe animation that starts the instant a class lands — the honest answer is to skip will-change entirely and let the browser promote on demand, because a single frame of setup at the start of a one-second animation is not worth a permanent layer.
The second thing to internalise is that will-change is not purely advisory. Naming a property that would create a stacking context if it had a non-initial value makes the element create one immediately — will-change: transform and will-change: opacity both do this. The element now establishes a stacking context whether or not it is currently transformed, so z-index ordering among its siblings can change, and a position: fixed descendant will position against this element rather than the viewport. Layouts that break "for no reason" after someone adds a performance hint are almost always this.
Variation: promoting a scroll container, and cleaning up from script
scroll-position targets a different optimisation from the layer allocation above. It tells the engine that a scrollable subtree is about to move, so it should pre-render content beyond the visible edge and avoid the blank-then-fill flash on a fast flick:
.timeline {
overflow-y: auto;
max-height: 70vh;
}
/* Only while the user is actually interacting with it. */
.timeline:hover,
.timeline:focus-within {
will-change: scroll-position;
}
When an animation is driven from script, the hint has to be removed explicitly, because no state change will do it for you:
const card = document.querySelector(".card");
card.addEventListener("pointerenter", () => {
card.style.willChange = "transform"; // promote, ahead of time
}, { passive: true });
card.addEventListener("pointerleave", () => {
card.style.willChange = "auto"; // demote, free the layer
}, { passive: true });
card.addEventListener("animationend", () => {
card.style.willChange = "auto";
});
Setting the property back to auto rather than removing the declaration is the reliable form, and it is worth doing on animationend as well as on pointer exit — an animation that ends while the pointer is still inside would otherwise hold its layer indefinitely. The reduced-motion block in the implementation applies the same reasoning declaratively, which lines up with the broader approach in reducing motion preferences in CSS.
Browser support
will-change has been supported in every major engine for around a decade, so it is universally available on evergreen browsers; anything older ignores the declaration and falls back to on-demand promotion, which is a performance difference rather than a visual one. No @supports guard is needed. The scroll-position and contents keywords parse everywhere will-change does, but the optimisations behind them are engine-specific and unspecified — treat them as hints that may be ignored, never as behaviour you depend on. Compositor handling of transform and opacity itself is consistent across all four engines.
Common pitfalls
| Pitfall | Cause | Resolution |
|---|---|---|
| Memory climbs steadily on a long list | will-change in a base rule applied to every row | Scope the hint to :hover, :focus-within, or an active class |
z-index ordering changed after adding the hint | will-change: transform created a stacking context | Set the intended z-index explicitly, or drop the hint |
A position: fixed child now scrolls with the page | The promoted ancestor became its containing block | Move the fixed element outside the promoted subtree |
| Text looks softer on the promoted element | The layer is rasterised separately and may lose subpixel antialiasing | Avoid promoting text-heavy elements, or promote only during the animation |
| The hint changed nothing measurable | It was applied in the same style change as the animation | Introduce a frame of separation, or remove the hint |
FAQ
What does will-change actually do?
It hints to the browser that a property is about to change, prompting it to promote the element to its own compositor layer ahead of time. That avoids the one-frame stall of promoting the layer at the moment the animation starts.
Why is overusing will-change bad?
Each promoted layer costs GPU memory roughly equal to its width times its height times four bytes, plus per-frame bookkeeping to composite it. Applying will-change widely, or leaving it on permanently, can exhaust memory and make compositing slower than not promoting at all.
Does will-change have side effects beyond performance?
Yes, and they are the most common surprise. A will-change value that could create a stacking context or containing block does so immediately, so z-index ordering can change and a descendant with position: fixed may start positioning against the promoted element instead of the viewport.
Why did adding will-change make no difference to my animation?
Because it was applied in the same style change that started the animation. The hint has to be in effect at least one frame earlier for the browser to do the preparation work in advance, otherwise the promotion still happens at the moment the animation begins.
Should I add will-change in my base CSS rule?
Usually no. Prefer adding it just before the change, for example on :hover or via a class, and removing it after. A permanent will-change in the base rule keeps a layer alive for the element's whole lifetime.
Related
- Performance & GPU Acceleration — the parent section on rendering performance.
- Optimizing CSS Animations for 60fps — the frame budget and which properties fit inside it.
- Profiling Animations in DevTools — inspect the layer list and confirm a promotion was worth it.
- Smooth Hover Effects Without JavaScript — the interaction pattern these hints are usually attached to.
- Building Responsive Cards With Container Queries — composited animation inside container-aware components.
Related articles
More pages in the same section.