CSS will-change: prepare an animation without breaking the page

will-change is a hint, not a speed switch. Used just before a transform or opacity animation it can help the first frames. Left on everything, it costs memory and changes how the page stacks.

will-change tells the browser that a property of an element is about to change, so it can prepare in advance. For transform and opacity that often means giving the element its own layer.

The short rule: add it just before an animation, remove it right after, and animate only transform and opacity.

Hover the card, then click it. The readout shows the hint being added and removed around the animation.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>will-change just in time</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .card {
    display: block; width: 220px; padding: 16px 18px; border: 0; border-radius: 12px;
    background: #fff; box-shadow: 0 6px 20px rgba(0, 0, 0, .12);
    font: inherit; text-align: left; cursor: pointer;
    transition: transform .6s ease;
  }
  .card span { display: block; color: #6b7280; font-size: 14px; margin-top: 4px; }
  .card.up { transform: translateY(-6px) scale(1.08); }
  .now { margin: 20px 0 8px; font-size: 15px; }
  .now b { font-family: ui-monospace, Consolas, monospace; }
  .now b.on { color: #9a3412; } .now b.off { color: #0f5132; }
  ol { margin: 0; padding-left: 22px; font: 13px/1.6 ui-monospace, Consolas, monospace; color: #374151; }
</style>
</head>
<body>
<button class="card" id="card"><b>Hover, then click</b><span>Or tap it on a phone.</span></button>
<div class="now">will-change right now: <b id="now"></b></div>
<ol id="log"></ol>

<script>
  const card = document.getElementById('card');
  const now = document.getElementById('now');
  const logEl = document.getElementById('log');
  let moving = false;

  function log(msg) {
    const li = document.createElement('li');
    li.textContent = msg;
    logEl.append(li);
    while (logEl.children.length > 5) logEl.firstChild.remove();
    const v = getComputedStyle(card).willChange;
    now.textContent = v;
    now.className = v === 'auto' ? 'off' : 'on';
  }

  // 1. Before the animation: the user is about to click.
  function prepare(why) {
    if (card.style.willChange === 'transform') return;
    card.style.willChange = 'transform';
    log(why + ': will-change added');
  }
  card.addEventListener('pointerenter', () => prepare('hover'));
  card.addEventListener('focus', () => prepare('focus'));
  card.addEventListener('pointerdown', () => prepare('press'));  // phones have no hover

  // 2. The animation itself.
  card.addEventListener('click', () => {
    moving = true;
    card.classList.toggle('up');
    log('click: transition starts');
  });

  // 3. After it: hand the memory back.
  card.addEventListener('transitionend', (e) => {
    if (e.propertyName !== 'transform') return;
    moving = false;
    card.style.willChange = '';
    log('transitionend: will-change removed');
  });

  // The user left without clicking: remove it too.
  function giveUp() {
    if (moving || !card.style.willChange) return;
    card.style.willChange = '';
    log('left without animating: removed');
  }
  card.addEventListener('pointerleave', giveUp);
  card.addEventListener('blur', giveUp);

  log('page loaded');
</script>
</body>
</html>
The hint arrives on hover, focus or press, and leaves on transitionend. Edit the code and the example reruns.

The rule is short. The rest of this guide covers why the timing matters, and the two side effects that surprise people.

What will-change actually does

The value is a list of property names:

.card { will-change: transform; }
.panel { will-change: transform, opacity; }
.reset { will-change: auto; }  /* the default: no hint */

Nothing moves because of it. The element looks exactly the same. It only tells the browser what is coming, and the browser decides what to do with that. Two special keywords also exist: scroll-position and contents.

For transform and opacity, the usual preparation is a separate layer. The element is drawn once into its own bitmap, and later frames can move or fade that bitmap without drawing the element again.

Add it just before, remove it after

A layer is not free. It holds the element's pixels in memory for as long as the hint stays. One card is nothing. Hundreds of elements with a permanent hint can use a lot of memory, which hurts most on phones.

Add the hint before the animation starts, and remove it when it ends. A hint on everything keeps every layer alive.
Add the hint before the animation starts, and remove it when it ends. A hint on everything keeps every layer alive.
  1. Before: add the hint when the user is about to start the animation, on pointerenter, focus or pointerdown.
  2. During: start the animation. The layer is already there.
  3. After: on transitionend or animationend, set the hint back to auto.

In CSS alone, a hover rule covers the "before" part, and the class that animates does the rest:

.card { transition: transform .4s ease; }
.card:hover, .card:focus-visible { will-change: transform; }
.card.open { transform: scale(1.08); }

Adding the hint in the same moment as the animation is too late to prepare anything. That is why the demo above adds it on hover and press, not on click.

Side effects: z-index and position: fixed

The specification gives will-change one more job. If any non-initial value of the named property would create a stacking context, the hint creates one too. The same holds for the box that position: fixed children are placed in.

Switch the value on .box and watch the readout. It measures what the browser actually does.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>will-change side effects</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  label { font-size: 15px; }
  select { font: inherit; padding: 4px 6px; margin-left: 4px; }
  .out { margin: 10px 0 14px; font-size: 14px; line-height: 1.6; }
  .out b.yes { color: #9a3412; } .out b.no { color: #0f5132; }
  .stage { position: relative; height: 190px; }
  .box {
    position: relative; width: 210px; height: 170px;
    background: #fff; border: 2px dashed #9aa3b2; border-radius: 10px;
  }
  .box > .name { position: absolute; left: 10px; bottom: 8px; font: 13px ui-monospace, Consolas, monospace; color: #6b7280; }
  .child {
    position: absolute; left: 70px; top: 20px; z-index: 5;   /* higher than the sibling */
    width: 120px; height: 70px; padding: 8px; box-sizing: border-box;
    background: #2563eb; color: #fff; border-radius: 8px; font-size: 13px;
  }
  .sibling {
    position: absolute; left: 150px; top: 45px; z-index: 1;  /* lower than the child */
    width: 130px; height: 80px; padding: 8px; box-sizing: border-box;
    background: #f59e0b; color: #1d2330; border-radius: 8px; font-size: 13px; text-align: right;
  }
  .badge {
    position: fixed; right: 8px; bottom: 8px;  /* normally: corner of the window */
    padding: 5px 9px; border-radius: 99px; background: #111827; color: #fff; font-size: 12px;
  }
</style>
</head>
<body>
<label>will-change on .box:
  <select id="wc">
    <option>auto</option><option>transform</option><option>opacity</option><option>filter</option>
  </select>
</label>
<div class="out">
  New stacking context: <b id="sc"></b><br>
  Fixed badge is placed by: <b id="cb"></b>
</div>
<div class="stage">
  <div class="box" id="box">
    <div class="child" id="child">child<br>z-index: 5</div>
    <div class="badge" id="badge">position: fixed</div>
    <span class="name">.box</span>
  </div>
  <div class="sibling" id="sibling">sibling<br>z-index: 1</div>
</div>

<script>
  const box = document.getElementById('box');
  const child = document.getElementById('child');
  const sibling = document.getElementById('sibling');
  const badge = document.getElementById('badge');
  const sc = document.getElementById('sc');
  const cb = document.getElementById('cb');
  const wc = document.getElementById('wc');

  function report() {
    // Which one is drawn on top where the two overlap?
    const c = child.getBoundingClientRect(), s = sibling.getBoundingClientRect();
    const x = (Math.max(c.left, s.left) + Math.min(c.right, s.right)) / 2;
    const y = (Math.max(c.top, s.top) + Math.min(c.bottom, s.bottom)) / 2;
    const trapped = !child.contains(document.elementFromPoint(x, y));
    sc.textContent = trapped ? 'yes, the z-index 5 child is now under the z-index 1 sibling' : 'no';
    sc.className = trapped ? 'yes' : 'no';

    // Is the fixed badge pinned to the window, or to .box?
    const b = badge.getBoundingClientRect(), bx = box.getBoundingClientRect();
    const inBox = Math.abs(b.right - (bx.right - 2 - 8)) < 3;  // 2px border + right: 8px
    cb.textContent = inBox ? '.box (it jumped inside the box)' : 'the window';
    cb.className = inBox ? 'yes' : 'no';
  }

  wc.addEventListener('change', () => {
    box.style.willChange = wc.value;
    report();
  });
  addEventListener('resize', report);
  report();
</script>
</body>
</html>
Pick transform, opacity or filter. The z-index 5 child drops under the z-index 1 sibling, and for transform and filter the fixed badge jumps into the box.
The same markup with and without will-change: transform on the parent.
The same markup with and without will-change: transform on the parent.
will-change value New stacking context Contains position: fixed children
transform Yes Yes
opacity Yes No
filter Yes Yes
left, top, width No No

The table is what the demo measured in Chromium, and it matches what the specification asks for. A stacking context traps the z-index of everything inside. CSS z-index explains why a z-index of 9999 then still loses.

The fixed-position change is the more confusing one. A "fixed" header inside the element stops sticking to the window and scrolls with its parent. CSS position covers what fixed normally does. The fix is to move the fixed element out of the hinted parent.

Why it will not fix a slow width animation

will-change: width is allowed, but it cannot remove the work. A new width moves the element's neighbors, so the browser has to calculate layout and paint again on every frame.

A width animation runs every stage on every frame. A transform or opacity animation on its own layer can often skip layout and paint.
A width animation runs every stage on every frame. A transform or opacity animation on its own layer can often skip layout and paint.

The fix is to change what you animate, not to add a hint:

Instead of Animate
left, top, margin transform: translate()
width, height transform: scale()
A growing box-shadow opacity of a pseudo-element that already has the big shadow

CSS transform covers translate and scale in detail. For motion driven by script, requestAnimationFrame keeps the updates in step with the screen.

Blurry text during and after a transform

Text on a layer can look softer than the text around it. There are two common reasons.

  • Scaling up a layer. In Chromium, a layer hinted with will-change: transform is not redrawn at each new scale, so enlarged text can look stretched. Removing the hint afterwards lets the browser redraw it sharp.
  • Half-pixel positions. A translate that ends on a value such as 10.5px can blur edges. Round the end position to whole pixels.

Both are one more reason to take the hint off after the animation.

Checking it in DevTools

Guessing about layers goes wrong easily, so look. In Chrome DevTools:

  • Performance panel: record while the animation plays. Purple "Layout" blocks on every frame mean you are animating a layout property.
  • Layers panel (under More tools): lists the layers on the page and why each one exists.
  • Rendering tab: Paint flashing shows what gets repainted, and Layer borders outlines each layer.

Other browsers have their own profilers. Compare before and after on the same page, and on a real phone if the page is meant for one.

A finished example: an animated card grid

This grid applies the rules together. Cards fly in with transform and opacity only. The hint goes on six cards just before the animation, and each card drops it when its own animation finishes.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Animated card grid</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #eceef1; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; font-size: 14px; }
  button { font: inherit; padding: 7px 16px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  .state { margin: 10px 0; font-size: 13px; color: #374151; }
  .state b { font-family: ui-monospace, Consolas, monospace; }
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
  .card {
    position: relative;  /* only needed for the left mode */
    height: 76px; padding: 12px; box-sizing: border-box; border-radius: 10px;
    background: #fff; box-shadow: 0 3px 10px rgba(0, 0, 0, .1); font-size: 14px;
  }
  .card i { display: block; width: 34px; height: 8px; border-radius: 4px; margin-top: 10px; }
  .note { margin-top: 12px; font-size: 13px; color: #6b7280; line-height: 1.45; }
</style>
</head>
<body>
<div class="bar">
  <button id="play">Play</button>
  <label><input type="radio" name="mode" value="fast" checked> transform + opacity</label>
  <label><input type="radio" name="mode" value="layout"> width + left</label>
</div>
<div class="state">Cards with will-change now: <b id="count">0</b> of 6</div>
<div class="grid" id="grid"></div>
<p class="note">Record both modes in your browser's DevTools Performance panel. The width + left mode makes the browser redo layout on every frame.</p>

<script>
  const colors = ['#60a5fa', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#2dd4bf'];
  const grid = document.getElementById('grid');
  const count = document.getElementById('count');
  const cards = colors.map((c, i) => {
    const el = document.createElement('div');
    el.className = 'card';
    el.innerHTML = 'Card ' + (i + 1) + '<i style="background:' + c + '"></i>';
    grid.append(el);
    return el;
  });

  const frames = {
    // Composited properties: no layout work while it plays.
    fast: [{ transform: 'translateY(24px) scale(.92)', opacity: 0 }, { transform: 'none', opacity: 1 }],
    // Layout properties: every frame changes the box size and position.
    layout: [{ left: '-30px', width: '40%', opacity: 0 }, { left: '0px', width: '100%', opacity: 1 }],
  };

  const show = () => (count.textContent = cards.filter((c) => c.style.willChange).length);

  function play() {
    const mode = document.querySelector('input[name=mode]:checked').value;
    cards.forEach((card) => {
      card.getAnimations().forEach((a) => a.cancel());  // stop a run in progress
      // Hint only the properties that will change, and only in this mode.
      card.style.willChange = mode === 'fast' ? 'transform, opacity' : '';
    });
    show();

    // Give the browser a frame to prepare, then start.
    requestAnimationFrame(() => {
      cards.forEach((card, i) => {
        const a = card.animate(frames[mode], { duration: 700, delay: i * 90, easing: 'ease-out', fill: 'backwards' });
        a.onfinish = () => {
          card.style.willChange = '';  // done: release the layer
          show();
        };
      });
    });
  }

  document.getElementById('play').addEventListener('click', play);
  play();
</script>
</body>
</html>
Play the transform + opacity mode and watch the counter fall back to 0. Then switch to width + left and record both in DevTools.
  • Prepare a frame early: set will-change, then start the animation inside requestAnimationFrame.
  • Release per card: the animation's onfinish sets the hint back, so the counter shows how many layers are still requested.
  • Compare honestly: the width + left mode looks similar on a fast computer. The difference shows up in the Performance recording, not by eye.

For the same idea on a whole page of moving layers, see the parallax guide. For animations that play on their own, CSS keyframes covers the syntax.

When it does not work

What you see Cause Fix
No improvement at all The animation changes width, height, left or top Animate transform and opacity instead
A fixed header or button jumps into a box and scrolls away An ancestor has will-change: transform or filter Move the fixed element out, or remove the hint
A dropdown or tooltip falls behind something with a lower z-index The hint made its ancestor a stacking context Remove the hint after the animation, or raise the ancestor's z-index
Text looks soft while or after scaling The layer is not redrawn at the new scale, or sits on a half pixel Remove the hint when done, round positions
The page gets slower, or crashes on a phone will-change on many elements, or in a * rule Hint only the elements about to move, and only for as long as they move

Animation problems are hard to show in a screenshot, and a video does not let anyone open DevTools on it. The working page does.

To send it, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can play the animation and profile it themselves. If you change the code later, the same link shows the new version.

Questions people ask

Does will-change make animations faster?

It can make the start smoother, because the browser prepares for the change before it happens, often by giving the element its own layer. It does not make a slow property fast. An animation of width or left still recalculates layout on every frame.

Should I put will-change: transform on every animated element?

No. Each hint can keep a layer in memory for as long as it is set. Add it to the few elements that are about to move, and remove it when the animation ends. MDN warns against applying it to too many elements.

Does will-change create a stacking context?

Yes, when the property it names would create one. will-change: transform, opacity or filter all make the element a stacking context, so a z-index inside it only ranks against its own children. will-change: transform and filter also make it the box that position: fixed children are placed in.

Is will-change: transform the same as transform: translateZ(0)?

They are used for a similar reason, but translateZ(0) is a real transform that happens to cause a layer in some browsers. will-change states the intent directly and does not change how the element is drawn. Both create a stacking context.

Can I write will-change in the stylesheet instead of JavaScript?

Yes, for an element that changes often, such as the pages of a slide deck that flip on every key press. For one-off animations, a :hover rule on the element or its parent, or a class added from script, keeps the hint short-lived.

Keep reading