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
transformandopacityoff the main thread, dropping per-frame latency below 5ms - Add it just before animation starts via
:hoveror JS; remove withwill-change: autowhen done - Works only for compositor-fast properties:
transform,opacity,filter,scroll-position
Quick example
.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-changeon:hoverso 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: autoin thetransitionendhandler - 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
/* 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.
el.addEventListener('mouseenter', () => {
el.style.willChange = 'transform';
});
el.addEventListener('transitionend', () => {
el.style.willChange = 'auto';
});Forgetting to reset after animation
/* 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
/* 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
/* 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+:
useWillChangehook pattern for parallax modals (react-spring issue #782) - Framer Motion: manages
will-changeinternally for spring animations - Ionic 7: sets
will-changeon modal overlays for iOS Safari PWA smoothness - Chrome DevTools: Gmail compose animation shows
will-changeusage 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
.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
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');
}.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
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 readyA concise answer to help you respond confidently on this topic during an interview.