Compositor-Safe Properties & will-change Budgeting
Every scroll-driven animation runs in one of two regimes: on the compositor thread, where the browser repositions an already-rasterized texture and never touches layout or paint, or on the main thread, where each scrolled frame re-enters the rendering pipeline for scroll animations at whatever stage the animated property dirties. Which regime you get is decided almost entirely by property choice, plus how deliberately you manage layer promotion. This guide — part of the Animation Performance Profiling & Optimization section — catalogs which properties are compositor-safe, explains what will-change actually does, and gives you a memory-budgeting method so promoted layers stay affordable on mobile GPUs.
Pages in this section
Syntax reference
will-change is a hint, not a command. It tells the browser which properties are about to change so it can prepare — typically by promoting the element to its own compositor layer — before the first animation frame:
/* Formal grammar: auto | <animateable-feature># */
.parallax-layer {
will-change: transform; /* single property hint */
}
.hero-card {
will-change: transform, opacity; /* comma-separated list */
}
.scroller {
will-change: scroll-position; /* hint upcoming scrolling, not property change */
}
.live-region {
will-change: contents; /* content will be replaced frequently */
}
| Value | Meaning | Typical browser response |
|---|---|---|
auto |
No hint (initial value) | No advance optimization; standard promotion heuristics apply |
<custom-ident> (e.g. transform) |
The named property is expected to change | Layer promotion if the property is compositor-animatable; same side effects as animating it |
scroll-position |
The element’s scroll offset will change | Pre-rasterizes content beyond the visible scrollport |
contents |
The element’s subtree will change | Caching of the element’s content is skipped |
Three grammar details matter in practice. First, the keywords will-change, none, all, auto, scroll-position, and contents are excluded from the <custom-ident> production — will-change: all is invalid and the declaration is dropped. Second, declaring will-change: transform applies the same document-level side effects as a non-none transform value: the element becomes a stacking context and a containing block for position: fixed and position: absolute descendants, even while no transform is applied. Third, will-change cannot be animated or transitioned itself; it flips instantaneously at style resolution.
Minimal working example
A two-layer parallax hero where every animated property is compositor-safe and promotion is explicit and scoped:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
.hero {
position: relative;
height: 100vh;
overflow: hidden;
}
/* Baseline: static positions for browsers without scroll timelines */
.hero__bg,
.hero__fg {
position: absolute;
inset: 0;
}
@supports (animation-timeline: scroll()) {
.hero__bg {
animation: drift-bg linear both;
animation-timeline: scroll(root block);
will-change: transform; /* pre-warm: promoted before frame 1 */
}
.hero__fg {
animation: drift-fg linear both;
animation-timeline: scroll(root block);
/* no will-change: the running compositor animation promotes it anyway */
}
}
/* Compositor-safe keyframes: transform only, no top/left */
@keyframes drift-bg {
to { transform: translateY(-12%) scale(1.06); }
}
@keyframes drift-fg {
to { transform: translateY(-32%); }
}
</style>
</head>
<body>
<section class="hero">
<div class="hero__bg"></div>
<div class="hero__fg"><h1>Compositor-only parallax</h1></div>
</section>
<main style="height: 300vh;"></main>
</body>
</html>
The keyframes touch only transform, so once each layer is rasterized the compositor replays the animation from scroll offsets without consulting the main thread. The will-change: transform on the background is a deliberate choice, not a reflex: it forces promotion during initial style resolution so the first scrolled frame does not pay a one-off paint-and-upload cost. The foreground omits it because an active compositor animation — including a scroll-driven one using the scroll() and view() timeline functions — promotes its target automatically in Chromium.
Layer promotion mechanics
A compositor layer is a rasterized texture the GPU can reposition, fade, and re-blend without repainting. Promotion happens for four distinct reasons, and knowing which one applies to each layer is the difference between an intentional budget and an accidental one:
- Explicit hints —
will-change: transform,will-change: opacity, or legacy hacks liketransform: translateZ(0). These promote immediately and permanently for as long as the declaration matches. - Active animations — a running CSS animation or Web Animations API animation targeting a compositor-animatable property promotes its element for the animation’s duration, then demotes it. Scroll-driven animations count as “running” for their whole attachment range, since the timeline can advance at any moment.
- Structural content —
<video>,<canvas>, iframes, and (engine-dependent)position: fixedorposition: stickyelements get layers because their content updates out of band with the page. - Overlap-induced promotion — an element that draws on top of an existing composited layer may itself be promoted so the browser can preserve paint order without re-rastering every frame. This is the mechanism behind “layer explosion”: one promoted hero image can drag a dozen overlapping siblings into their own layers.
The lifecycle for each layer is paint → rasterize into tiles → upload to GPU memory → composite. Only the last step repeats per frame for a compositor-driven animation. If the layer’s content itself changes — text updates, a filter: blur() radius animates in a way the engine cannot interpolate on the GPU, or the layer is resized — the tiles are invalidated and the paint/rasterize/upload stages run again, which is exactly the cost promotion was meant to avoid.
The memory cost of one layer is easy to estimate:
// Bytes ≈ cssWidth × cssHeight × devicePixelRatio² × 4 (RGBA)
function layerBytes(cssWidth, cssHeight, dpr = window.devicePixelRatio) {
return cssWidth * cssHeight * dpr * dpr * 4;
}
// Full-viewport layer on a 390×844 CSS px phone at 3× DPR:
layerBytes(390, 844, 3); // ≈ 11.9 MB — for ONE layer
Chromium rasterizes layers into 256×256 (or larger) tiles and may skip tiles far outside the viewport, so real allocations can be somewhat lower — but the estimate is the right order of magnitude for budgeting, and other engines keep full backing stores in more cases.
Compositor-safe properties
The compositor can animate a property only if changing it requires neither layout nor repaint — just re-transforming or re-blending existing textures. That gives you a short safe list and a long unsafe one:
| Property | Layout? | Paint? | Compositor-animatable? | Verdict for scroll effects |
|---|---|---|---|---|
transform (and translate, rotate, scale) |
No | No | Yes | Safe — the default tool |
opacity |
No | No | Yes | Safe |
filter |
No | No | Partially | Safe in Chromium for most functions; test per engine |
backdrop-filter |
No | No | Partially | Expensive; forces a render surface — measure first |
clip-path |
No | Yes | Rarely | Repaints per frame; avoid in scroll-driven keyframes |
top, left, right, bottom |
Yes | Yes | No | Unsafe — full pipeline every frame |
width, height, margin, padding |
Yes | Yes | No | Unsafe — layout of the element and its subtree |
background-color, color, box-shadow |
No | Yes | No | Repaint per frame; acceptable only for tiny areas |
The filter row deserves its caveats spelled out. In Chromium, animating filter on a promoted layer generally stays on the compositor, but any filter other than opacity() forces the layer into an intermediate render surface — an extra offscreen texture that is filtered and then composited, roughly doubling that layer’s memory and adding a GPU pass. Animating a blur() radius is the worst case: large radii sample many pixels per output pixel, and mobile GPUs bandwidth-limit quickly. Firefox historically ran more filter animations on the main thread. The pragmatic rule: transform and opacity are unconditionally safe; filter is conditionally safe and must be profiled on real hardware, which the Chrome DevTools profiling workflow for scroll animations walks through frame by frame.
The individual transform properties translate, rotate, and scale composite identically to transform and are often better in scroll-driven work because each can be animated on its own timeline without clobbering the others in the cascade.
One scroll-specific subtlety: a scroll-driven animation whose keyframes touch any non-composited property is demoted entirely to the main thread — the whole animation, not just the offending property. One background-color stop in an otherwise transform-only keyframe block silently converts your free compositor animation into per-frame main-thread work. The comparison of transform against top/left in scroll-driven effects measures exactly what that demotion costs.
Budgeting will-change: the layer memory math
will-change is free to type and expensive to hold. Every matching element keeps a GPU-resident texture alive whether or not it is currently animating, so budgeting means multiplying layer estimates by layer counts and comparing against what the device can spare.
Worked example for a 390×844 CSS px viewport at 3× device pixel ratio:
| Promoted element | CSS size | Physical pixels | GPU memory |
|---|---|---|---|
| Full-viewport parallax background | 390×844 | 1170×2532 | ≈ 11.9 MB |
| Hero foreground band | 390×300 | 1170×900 | ≈ 4.2 MB |
| One product card | 320×200 | 960×600 | ≈ 2.3 MB |
Ten product cards with will-change |
— | — | ≈ 23 MB |
| Sticky header | 390×64 | 1170×192 | ≈ 0.9 MB |
Ten carelessly promoted cards cost twice the parallax system they were meant to support. A workable rule of thumb for mid-range mobile hardware is to keep the sum of intentionally promoted layers under roughly 50 MB; integrated GPUs share memory with the system, and when the total climbs the browser starts evicting and re-rasterizing tiles mid-scroll — visible as checkerboard flashes or late-arriving content. Exceeding the budget does not fail loudly; it fails as jank, dropped frames, and in the worst cases renderer crashes on low-memory devices, all of which surface in field data as the interaction and stability regressions covered in Core Web Vitals for animated interfaces.
Budget procedure:
- List every element with
will-change, a persistent 3D transform hack, or a scroll-driven animation attached across a long range. - Estimate each with
width × height × dpr² × 4at your largest supported DPR. - Sum. If over budget, remove
will-changefrom elements whose animations already self-promote, shrink promoted elements (promote the inner moving part, not the whole card), and stagger promotion so offscreen elements are not resident.
Common implementation patterns
Pattern 1: Let the animation promote for itself
The cheapest will-change is the one you never write. Because an attached scroll-driven animation on transform or opacity promotes its own target, most elements need nothing extra:
@supports (animation-timeline: view()) {
.reveal-card {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 80%;
/* no will-change — promotion rides on the animation itself */
}
}
Reserve explicit will-change for elements where profiling shows a first-frame rasterization stutter — typically large, paint-heavy layers like full-bleed images.
Pattern 2: Scope will-change to the animation’s lifetime in JavaScript
For interaction-triggered animations (as opposed to always-attached scroll timelines), add the hint just before motion and remove it after:
const card = document.querySelector('.flip-card');
card.addEventListener('pointerenter', () => {
// Promote one gesture ahead of the animation
card.style.willChange = 'transform';
});
card.addEventListener('transitionend', () => {
// Demote: release the GPU texture once motion settles
card.style.willChange = 'auto';
});
Pattern 3: Promote near the viewport, demote outside it
For long pages with many scroll-animated elements, keep only near-viewport elements resident:
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
// Promote shortly before entry, demote well after exit
entry.target.style.willChange = entry.isIntersecting ? 'transform' : 'auto';
}
}, { rootMargin: '50% 0px' }); // pre-warm half a viewport early
document.querySelectorAll('.reveal-card').forEach((el) => observer.observe(el));
The generous rootMargin gives the browser time to paint and upload the texture before the element’s animation range begins, so promotion never lands mid-frame.
Pattern 4: Do not promote under reduced motion
If motion is suppressed, the layers serve no purpose. Pair your prefers-reduced-motion overrides for scroll-driven animations with demotion:
@media (prefers-reduced-motion: reduce) {
.hero__bg,
.hero__fg,
.reveal-card {
animation: none;
animation-timeline: auto;
will-change: auto; /* release every pre-warmed layer */
}
}
Browser support and @supports guard
will-change itself is old and universal; the scroll-driven timelines that make budgeting urgent are not:
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
will-change |
36 | 79 | 36 | 9.1 |
translate / rotate / scale properties |
104 | 104 | 72 | 14.1 |
animation-timeline: scroll() / view() |
115 | 115 | Behind a pref (Nightly) | In active development (Technology Preview) |
Because will-change support long predates animation-timeline, guard the pair together so browsers that will never run the scroll animation also never allocate its layers:
/* Promote only where the scroll-driven animation can actually run */
@supports (animation-timeline: scroll()) {
.parallax-layer {
animation: drift linear both;
animation-timeline: scroll(root);
will-change: transform;
}
}
Without the guard, Firefox stable and current Safari releases would hold the promoted layer’s memory while showing a static page — pure cost, zero benefit. The broader guard discipline, including fallback ordering, is covered in browser support and progressive enhancement for scroll animations.
Gotchas and failure modes
-
will-changeon everything.* { will-change: transform; }or a utility class stamped across a component library asks the browser to keep hundreds of textures resident. Engines defend themselves by ignoring the hint past internal limits, so you get unpredictable promotion plus wasted memory. If more than a handful of elements per viewport carry the hint, the budget math above has already failed. -
Permanent hints for momentary animations.
will-changeleft in a stylesheet “because the card animates on hover” holds GPU memory for every card, all session long, for an animation that runs for 200 ms. Scope it (Patterns 2–3) or drop it (Pattern 1). -
Broken
position: fixeddescendants. Becausewill-change: transformmakes the element a containing block, anyposition: fixeddescendant starts positioning against it instead of the viewport. A modal or sticky CTA nested inside a promoted hero visually “sticks” to the hero. Move fixed elements outside promoted subtrees. -
Stacking-context surprises. Promotion creates a stacking context, so
z-indexvalues that used to interleave siblings stop doing so. If addingwill-changereorders your page’s layers visually, audit the stacking tree rather than escalatingz-index. -
Animating
width/height/topin scroll keyframes. The classic anti-pattern: a “parallax” built ontop, or a progress bar animatingwidth, forces style → layout → paint → composite on every scrolled frame, and pins the scroll-driven animation to the main thread where it competes with script. Rebuild withtransform: translateY()andtransform: scaleX()respectively. -
One dirty property demotes the whole animation. Mixed keyframes (
transform+box-shadow, say) run entirely on the main thread. Split the compositor-safe motion and the paint-affecting flourish into separate animations, and shorten the paint-heavy one’s range. -
Text rendering shifts on promotion. Rasterizing text into a layer can switch it from subpixel to grayscale antialiasing, producing a visible weight change the moment
will-changeapplies. Promote before the element is visible, or accept grayscale antialiasing from the start. -
Layer explosion by overlap. One promoted element under a stack of positioned siblings can force each sibling into its own layer to preserve paint order. Symptoms: dozens of unexplained entries in the Layers panel. Fix by lowering the promoted element’s overlap footprint or restructuring z-order.
Performance checklist
- Animate only
transform(ortranslate/rotate/scale),opacity, and profiledfiltervalues in scroll-driven keyframes — nothing that appears in the layout or paint rows of the matrix above. - Audit every keyframe block for a single non-composited property; one is enough to demote the whole animation to the main thread.
- Prefer self-promotion by the running animation; add
will-changeonly where a first-frame stutter is measured. - Estimate each promoted layer at
width × height × dpr² × 4bytes and keep the page total under roughly 50 MB for mobile. - Promote the smallest moving element — the inner image, not the whole card with its padding and shadow.
- Scope
will-changewith IntersectionObserver or animation lifecycle events;will-change: autois the steady state. - Guard promotion inside
@supports (animation-timeline: scroll())so non-supporting engines allocate nothing. - Demote everything under
prefers-reduced-motion: reducealongside theanimation-timeline: autoreset.
Debugging and DevTools verification
- DevTools → More tools → Layers: every entry should trace to a reason you recognize (your
will-change, your animation, a video). Check each layer’s memory estimate against your budget table. - Rendering tab → enable Layer borders: orange borders outline composited layers in the viewport; a page shimmering with borders has an overlap explosion.
- Rendering tab → Paint flashing: scroll the page. Compositor-driven animations produce no green flashes; any flash during scroll means a property in your keyframes is repainting.
- Performance panel → record a scroll: transform/opacity scroll animations appear on the compositor track with an idle main thread;
LayoutandPaintblocks repeating per frame mean demotion. - Console:
document.getAnimations()— inspect each animation’s target and keyframes for non-composited properties that slipped in from a shared mixin. - Re-test at your highest supported device pixel ratio; layer memory scales with its square, so a page that budgets fine at 1× can thrash at 3×.
Related
- Diagnosing Main-Thread Jank in the Performance Panel — reading flame charts to find which pipeline stage your scroll frames lose time in
- When to Use CSS Animations Over JavaScript Libraries — how declarative animations keep compositor eligibility that script-driven ones forfeit
- Parallax Effects with Pure CSS — full parallax recipes built exclusively from the compositor-safe property set
- Motion Scaling & User Preferences — scaling transform distances down instead of off, without changing layer behavior