Skip to main content

Browser Compositor: Why Transform Matters

The browser compositor is a subsystem that combines rasterized content from multiple layers and blends them into the final display. It runs on a separate thread from JavaScript and can operate at 60 fps (or 120 fps) without blocking user code. Understanding the compositor is the key to writing animation code that actually performs. This article explains how the compositor works, which CSS properties are compositor-friendly, and how to strategically promote elements to their own composite layer for maximum speed.

What Is the Compositor?

After the browser executes JavaScript, calculates styles, performs layout, and paints elements to bitmaps, the compositor's job is to take those bitmaps (organized into layers) and combine them for display. The compositor can reorder layers, apply transforms, adjust opacity, and reposition elements without triggering a new layout or paint pass—because the content is already rasterized.

Think of the compositor like a film editor with a stack of transparent sheets:

  1. Each sheet is painted once (paint step).
  2. The editor can slide, scale, rotate, and adjust transparency of the sheets (compositor step).
  3. The final image is the blend of all sheets.

If you want to move a sheet, you tell the editor to shift it. The editor does not repaint the sheet; it just repositions the bitmap. This is why transform animations are so fast.

Compositor-Friendly Properties

Only two CSS properties can be animated purely by the compositor without triggering layout or paint:

PropertyEffectCost
transformTranslate, rotate, scale, skewCompositor only (Very fast)
opacityFade in/outCompositor only (Very fast)

All other animated properties trigger layout recalculation, paint, or both, which blocks the compositor.

Example: Transform (Fast)

import { useState } from 'react';

export default function FastTransformAnimation() {
const [isSlid, setIsSlid] = useState(false);

return (
<div
onClick={() => setIsSlid(!isSlid)}
style={{
// Compositor-friendly: no layout or paint
transform: isSlid ? 'translateX(200px) scale(1.2)' : 'translateX(0) scale(1)',
transition: 'transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
width: '100px',
height: '100px',
backgroundColor: '#4ecdc4',
cursor: 'pointer',
}}
>
Click me
</div>
);
}

Example: Opacity (Fast)

import { useState } from 'react';

export default function FastOpacityAnimation() {
const [isFaded, setIsFaded] = useState(false);

return (
<div
onClick={() => setIsFaded(!isFaded)}
style={{
// Compositor-friendly: no paint (opacity is applied to the layer itself)
opacity: isFaded ? 0.5 : 1,
transition: 'opacity 0.4s ease',
width: '100px',
height: '100px',
backgroundColor: '#4ecdc4',
cursor: 'pointer',
}}
>
Click me
</div>
);
}

Non-Compositor-Friendly Properties

These properties trigger layout or paint, making them expensive to animate:

Layout-Triggering Properties (Worst)

export default function SlowLayoutAnimation() {
const [isSlid, setIsSlid] = useState(false);

return (
<div
onClick={() => setIsSlid(!isSlid)}
style={{
// Triggers layout recalculation: avoid!
left: isSlid ? '200px' : '0px',
transition: 'left 0.4s ease',
position: 'relative',
width: '100px',
height: '100px',
backgroundColor: '#ff6b6b',
cursor: 'pointer',
}}
>
Avoid me
</div>
);
}

This element animates left, which requires the browser to recalculate positions of sibling and parent elements, trigger paint, and then composite. It is 100x slower than transform: translateX().

Paint-Triggering Properties (Bad)

export default function SlowPaintAnimation() {
const [isHovered, setIsHovered] = useState(false);

return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
// Triggers paint: slower than compositor-only
backgroundColor: isHovered ? '#ff6b6b' : '#4ecdc4',
transition: 'background-color 0.4s ease',
width: '100px',
height: '100px',
cursor: 'pointer',
}}
>
Hover me
</div>
);
}

This animates backgroundColor, which requires the browser to repaint the element's content. The repainting is often fast on simple shapes, but it is still slower than compositor-only properties.

Creating Composite Layers

The browser automatically creates composite layers for elements with certain properties (e.g., will-change: transform). You can explicitly promote an element to its own layer using the will-change CSS property:

import { useState } from 'react';

export default function CompositeLayerExample() {
const [isAnimating, setIsAnimating] = useState(false);

return (
<div
onClick={() => setIsAnimating(!isAnimating)}
style={{
// Explicitly promote to composite layer
willChange: 'transform',
transform: isAnimating ? 'translateX(100px)' : 'translateX(0)',
transition: 'transform 0.4s ease',
width: '100px',
height: '100px',
backgroundColor: '#4ecdc4',
cursor: 'pointer',
}}
>
Animated Box
</div>
);
}

Using will-change: transform tells the browser to create a separate layer for this element before the animation starts. The browser can then optimize this layer independently of the rest of the page.

Layer Promotion Rules

  • Use will-change: transform for elements that will be transformed.
  • Use will-change: opacity for elements that will fade.
  • Do not overuse will-change; excessive layer promotion consumes memory and can actually slow down rendering. Use it only for elements that animate frequently or continuously.
  • Remove will-change after the animation completes if animating infrequently.

Composite Layer Strategies

Strategy 1: Isolate Complex DOM Subtrees

If you have a complex DOM subtree (many children), wrap it in a container and promote the container to a layer:

export default function ComplexAnimation() {
return (
<div style={{ willChange: 'transform' }}>
{/* All 1000 children are now on the same layer and animate together */}
{Array.from({ length: 1000 }).map((_, i) => (
<div key={i}>Complex component</div>
))}
</div>
);
}

Animating the container layer is much faster than animating 1000 individual children.

Strategy 2: Avoid Paint Inside Animated Regions

If an animated element contains rapidly-changing content (e.g., a counter), paint reflows will stutter the animation:

// Bad: animation stutters because counter repaints on every update
export default function BadCounterAnimation() {
const [count, setCount] = useState(0);
const [isSliding, setIsSliding] = useState(false);

return (
<div
style={{
transform: isSliding ? 'translateX(100px)' : 'translateX(0)',
transition: 'transform 0.4s ease',
}}
>
Count: {count}
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}

// Good: animation and counter updates are decoupled
export default function GoodCounterAnimation() {
const [count, setCount] = useState(0);
const [isSliding, setIsSliding] = useState(false);

return (
<div
style={{
transform: isSliding ? 'translateX(100px)' : 'translateX(0)',
transition: 'transform 0.4s ease',
}}
>
{/* Separate layer, animation is not affected by counter updates */}
<div style={{ willChange: 'transform' }}>Static content</div>
<div>
Count: {count}
<button onClick={() => setCount(count + 1)}>+</button>
</div>
</div>
);
}

Key Takeaways

  • The compositor is a separate rendering thread that combines pre-rasterized layers; it is the fastest step in the rendering pipeline.
  • Only transform and opacity can be animated without triggering layout or paint, making them 10–100x faster than other properties.
  • Animating left, top, or color triggers layout or paint, which forces the compositor to wait and blocks 60 fps animation on complex pages.
  • Use will-change: transform to explicitly promote frequently-animated elements to their own composite layer.
  • Overusing will-change wastes memory; reserve it for genuinely frequent animations.

Frequently Asked Questions

What is the memory cost of creating a composite layer?

Each composite layer requires GPU memory to store the rasterized bitmap. A typical layer costs between 1–10 MB depending on size and color depth. Creating too many layers (>20 on a mobile device) can exhaust GPU memory and slow rendering. Monitor with Chrome DevTools > Rendering > Show compositing borders to visualize layers.

Does will-change: transform guarantee smoother animation?

Not automatically. will-change merely tells the browser to create a layer ahead of time, avoiding the setup cost of layer creation during the animation. If the animation is still triggering layout or paint (e.g., because you animated left instead of transform), the animation will stutter regardless of layer promotion. Animate compositor-friendly properties first; use will-change as a secondary optimization.

Can I use transform3d() instead of transform for animation?

transform (2D) and transform: translate3d() (3D) both route through the compositor and have identical performance. Use whichever is semantically clearer. 3D transforms enable hardware acceleration (GPU), but modern browsers enable GPU acceleration for all compositor operations, so the difference is negligible in 2026.

Why does opacity feel slower than transform?

Opacity sometimes appears slower because humans perceive movement (translate) faster than fade (opacity). If you need the animation to feel equally snappy, increase the duration of the opacity animation slightly or use an ease curve (e.g., cubic-bezier(0.25, 0.46, 0.45, 0.94)) to make opacity accelerate faster. The underlying performance is identical.

How do I check if my animation is using the compositor?

In Chrome DevTools, enable Settings > More tools > Rendering > Show compositing borders. Green borders indicate composite layers. If your animated element has no green border, it is not on its own layer and may be slower. Use will-change to promote it.

Further Reading