Skip to main content

CSS will-change property

will-change tells the browser which CSS properties an element will animate, so it can promote that element to a GPU compositor layer before the animation starts.

Theory

TL;DR

  • Think of it as preordering at a restaurant: the kitchen starts cooking before you arrive, not after you sit down
  • Without it: the browser repaints on the CPU each frame, eating into the 16ms budget at 60fps
  • With it: GPU composites transform and opacity off the main thread, dropping per-frame latency below 5ms
  • Add it just before animation starts via :hover or JS; remove with will-change: auto when done
  • Works only for compositor-fast properties: transform, opacity, filter, scroll-position

Quick example

css
.card { transition: transform 0.3s ease; /* No will-change: CPU repaints every frame */ } .card:hover { will-change: transform; /* Browser creates a GPU layer on hover */ transform: scale(1.05); /* GPU composites this — main thread stays free */ } /* Open DevTools > Layers panel: one new layer appears on hover, gone after */

When .card:hover fires, the browser sees will-change before the transition begins. That is enough notice to allocate a compositor layer. The scale animation then runs entirely off the main thread.

How layer promotion actually works

By default, every paint happens on the main thread. A transform or opacity change without a compositor layer means the CPU recalculates pixels, uploads them to GPU memory, and blends. At 60fps you have 16ms per frame. A busy main thread eats that fast.

will-change changes that flow. In Chrome's Blink engine, will-change: transform triggers a cc::Layer allocation. The GPU process rasterizes the element into tiles once, then composites every frame without touching layout or paint. Per-frame cost drops from 50ms+ to under 5ms for transforms and opacity.

Each layer costs GPU memory, typically a few MB depending on element size. Fifty layers means 200MB+ on mobile. Chrome keeps a soft budget of around 20 active layers per stacking context and auto-demotes extras after roughly 250ms idle (Chrome 110+). Safari demotes faster, around 60ms.

When to use

  • Hover animations: set will-change on :hover so the GPU layer exists when the transition fires
  • JS-driven animations: element.style.willChange = 'transform' one requestAnimationFrame call before starting the animation
  • Modals: set on open, remove with will-change: auto in the transitionend handler
  • Scroll-driven effects: only on elements currently in the viewport, never on an entire list at once

Difference from transform: translateZ(0)

transform: translateZ(0) forces a GPU layer immediately and holds it permanently. It predates will-change and was the standard hack before 2015. will-change allocates lazily and can be cleaned up with will-change: auto. For static elements that never animate, translateZ(0) backfires by holding a layer forever. For controlled animations, will-change is the better choice because the layer lifecycle is yours to manage.

Common mistakes

Applying to everything on page load

css
/* 100 cards = 100+ GPU layers = 500MB RAM, scroll jank */ .card { will-change: transform; }

I audited a product grid like this once. Every .product-card had will-change: transform in the global stylesheet. DevTools Layers showed 80+ active compositor layers and scroll frame rate was stuck at 30fps. Removing the global rule and adding will-change on mouseenter brought it back to a steady 60fps.

javascript
el.addEventListener('mouseenter', () => { el.style.willChange = 'transform'; }); el.addEventListener('transitionend', () => { el.style.willChange = 'auto'; });

Forgetting to reset after animation

css
/* Layer stays allocated even after animation ends */ .animate { will-change: transform; animation: spin 2s; }

On iOS Safari this causes a 30% performance drop on subsequent scrolling (WebKit bug 148037). Always reset: call el.style.willChange = 'auto' in the animationend handler.

Using it on non-composited properties

css
/* will-change: width gives no GPU compositing benefit */ .bad { will-change: width; transition: width 1s; }

Only transform, opacity, filter, and scroll-position get compositor promotion. width, height, color, and margin do not. The browser simply ignores the hint for those. If you need a width-change effect, fake it with transform: scaleX().

Setting it on scroll lists without limits

css
/* 50 list items * will-change = memory spike on mid-range Android */ .scroll-list > * { will-change: transform; }

Chrome 120+ auto-demotes extras after 250ms idle, but the allocation spike itself causes jank. Apply will-change only to items near the viewport via JS.

Real-world usage

  • GSAP 3.12+: gsap.set(el, { willChange: 'transform' }) before any tween
  • React Spring v9+: useWillChange hook pattern for parallax modals (react-spring issue #782)
  • Framer Motion: manages will-change internally for spring animations
  • Ionic 7: sets will-change on modal overlays for iOS Safari PWA smoothness
  • Chrome DevTools: Gmail compose animation shows will-change usage in the Layers panel

Follow-up questions

Q: Which CSS properties actually get GPU compositor promotion from will-change?
A: transform, opacity, filter, and scroll-position. Properties like width, height, top, left, and color do not trigger promotion. The browser ignores will-change for those.

Q: What is the difference between will-change: transform and transform: translateZ(0)?
A: translateZ(0) creates a GPU layer immediately and keeps it permanently. will-change allocates lazily and cleans up with will-change: auto. For animations you control, will-change is cleaner because you own the layer lifecycle.

Q: How many layers can Chrome handle before performance degrades?
A: Around 20 active layers per stacking context. Chrome 110+ auto-demotes extras after 250ms idle. Safari does it faster, around 60ms. You can check memory pressure with performance.measureUserAgentSpecificMemory().

Q: Why does will-change: auto not free the GPU layer immediately?
A: Budget pooling has a minimum idle delay of around 128ms. The browser batches demotions to prevent thrashing. Check the Layers panel in DevTools after triggering will-change: auto to confirm the demotion.

Q: You have a scrollable list of 100 animated cards, all with will-change: transform. Mobile users report scroll lag. How do you fix it without IntersectionObserver?
A: Virtualize the list with react-window so only 6-10 DOM nodes exist at once. Apply will-change to visible items only via a ResizeObserver throttle. This cuts active layers from 100 to 3-5. Another option: apply will-change on mouseenter and remove on transitionend, so the layer exists for roughly 300ms per card maximum.

Examples

Basic: hover card with GPU compositing

css
.card { width: 200px; padding: 20px; background: #f0f0f0; transition: transform 0.3s ease, box-shadow 0.3s ease; } /* will-change is set BEFORE the transition fires */ .card:hover { will-change: transform; transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }

The GPU layer gets allocated when :hover matches, before the CSS transition starts. The translateY animation runs fully on the compositor. Check DevTools Layers panel: one extra layer on hover, gone on mouse-out.

Intermediate: modal with JS lifecycle management

javascript
const modal = document.querySelector('.modal'); function openModal() { // Allocate GPU layer one frame before animation starts modal.style.willChange = 'transform, opacity'; requestAnimationFrame(() => { modal.classList.add('modal--visible'); }); } function closeModal() { modal.addEventListener('transitionend', () => { // Recycle the layer after animation completes modal.style.willChange = 'auto'; }, { once: true }); modal.classList.remove('modal--visible'); }
css
.modal { opacity: 0; transform: scale(0.92); transition: transform 0.25s ease, opacity 0.25s ease; } .modal--visible { opacity: 1; transform: scale(1); }

Setting will-change before requestAnimationFrame gives the browser one full frame to allocate the layer. The { once: true } option removes the transitionend listener automatically, preventing stacked handlers on repeated open/close cycles.

Advanced: viewport-scoped will-change for long lists

javascript
function manageWillChange(container) { const items = Array.from(container.querySelectorAll('.item')); function update() { items.forEach(item => { const { top, bottom } = item.getBoundingClientRect(); const inViewport = bottom > 0 && top < window.innerHeight; // Promote items near the viewport, demote everything else item.style.willChange = inViewport ? 'transform, opacity' : 'auto'; }); } let ticking = false; window.addEventListener('scroll', () => { if (!ticking) { requestAnimationFrame(() => { update(); ticking = false; }); ticking = true; } }); update(); // Run once on mount }

At any scroll position, at most 5-8 layers are active regardless of list length. The requestAnimationFrame throttle caps updates at 60 times per second. Chrome 120+ would auto-demote off-screen items after 250ms anyway, but explicit management produces the same behavior in Safari and Firefox.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?