GPU Compositing: transform vs left/top
Two Animations, Wildly Different Performance
Pop quiz before we even start: these two CSS animations produce the exact same visual result — moving a box 300px to the right. One runs at 60fps on a budget phone. The other drops frames on a high-end desktop. Can you guess which is which?
/* ❌ Animates left — triggers layout + paint + composite every frame */
@keyframes move-left {
from { left: 0; }
to { left: 300px; }
}
/* ✅ Animates transform — triggers composite only */
@keyframes move-transform {
from { transform: translateX(0); }
to { transform: translateX(300px); }
}
Same pixels, wildly different performance. Understanding why requires knowing how the browser's compositing architecture works.
The browser has two threads that work together: the main thread (runs JavaScript, calculates styles, performs layout, generates paint commands) and the compositor thread (takes painted layers, applies transforms and opacity, composites the final frame, sends to GPU). Animations that only need the compositor thread bypass the main thread entirely. The main thread could be running a 200ms JavaScript task, and compositor-only animations still run at 60fps because they are on a different thread.
The Full Pipeline vs Compositor-Only
When you animate left:
Every frame:
Main Thread: Style → Layout → Paint → [hand off]
Compositor Thread: Composite → GPU → Screen
When you animate transform:
Every frame:
Main Thread: (nothing — not involved)
Compositor Thread: Apply transform to existing texture → GPU → Screen
Compositor-Only Properties
So which properties get this special treatment? Only four CSS properties can be animated entirely on the compositor thread:
| Property | What the compositor does |
|---|---|
transform | Move, rotate, scale, skew the layer's GPU texture |
opacity | Adjust the layer's alpha channel |
filter | Apply GPU-accelerated visual effects (blur, brightness, etc.) |
backdrop-filter | Apply filters to content behind the element |
Everything else — left, top, width, height, margin, padding, border, font-size, color, background — requires the main thread for either layout recalculation, repaint, or both.
How GPU Textures Work
To understand why this is fast, you need to know what happens under the hood. When an element is promoted to its own compositing layer, the browser:
- Paints the element into a bitmap (rasterization)
- Uploads that bitmap to the GPU as a texture
- Stores the texture in GPU memory (VRAM)
- Composites by positioning textures relative to each other
The texture is basically a screenshot — once painted and uploaded, the compositor can move, rotate, scale, or fade it without ever repainting. This is the key insight behind why transform and opacity are fast: the compositor just shuffles existing textures around rather than generating new ones.
Main Thread GPU Memory
┌──────────┐ ┌──────────────────┐
│ Paint │ ──rasterize──→ │ Texture: Layer 1 │
│ element │ upload │ (bitmap of pixels)│
└──────────┘ └──────────────────┘
Compositor Thread GPU
┌──────────┐ ┌──────────────────┐
│ Transform │ ──move/scale──→ │ Draw texture at │
│ the layer │ the texture │ new position │
└──────────┘ └──────────────────┘
Each GPU texture consumes memory proportional to the element's pixel dimensions. A 1000x1000 element at 2x device pixel ratio creates a 2000x2000 texture = 16MB of GPU memory (4 bytes per pixel * 4 million pixels). Promoting too many elements to layers can exhaust GPU memory, causing the browser to fall back to software rendering — which is far slower than not having layers at all.
Layer Creation: What Gets Promoted
The browser promotes elements to their own compositing layers when:
/* Explicit promotion */
.promoted {
will-change: transform; /* Tells browser: I'll animate this */
transform: translateZ(0); /* Old hack, same effect */
}
/* Implicit promotion */
.video-element { } /* <video> elements */
.canvas-element { } /* <canvas> elements */
.has-3d-transform {
transform: rotate3d(1, 0, 0, 45deg); /* Any 3D transform */
}
.fixed-element {
position: fixed; /* Fixed positioning (in many browsers) */
}
.animated {
animation: slide 1s; /* Active CSS animation on compositor property */
transition: transform 0.3s; /* Active CSS transition on compositor property */
}
Elements also get promoted when they overlap a composited layer and the browser cannot determine stacking order without a separate layer (squashing heuristic).
Practical Animation Comparisons
Slide-In Animation
/* ❌ Layout every frame */
.panel-left {
position: absolute;
transition: left 0.3s ease;
left: -300px;
}
.panel-left.open {
left: 0;
}
/* ✅ Compositor only */
.panel-transform {
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.panel-transform.open {
transform: translateX(0);
}
Fade + Scale Entrance
/* ✅ All compositor properties */
.card-enter {
opacity: 0;
transform: scale(0.95) translateY(10px);
transition: opacity 0.2s, transform 0.3s ease-out;
}
.card-enter.visible {
opacity: 1;
transform: scale(1) translateY(0);
}
Expanding Card (Layout Required)
Sometimes you genuinely need layout to change — surrounding elements must reflow:
/* When layout change is intentional, minimize the damage */
.expandable {
contain: layout style; /* Limit reflow scope */
transition: height 0.3s ease;
overflow: hidden;
}
If layout change is only visual (no sibling reflow needed), use scale instead:
.expandable-visual {
transform-origin: top left;
transition: transform 0.3s ease;
}
.expandable-visual.expanded {
transform: scaleY(1.5);
}
Why does the compositor thread exist?
Early browsers did everything on the main thread — JavaScript, layout, paint, and compositing. When JavaScript ran for 100ms, scrolling froze. Chrome introduced the compositor thread (Project CC, around 2012) specifically to decouple scrolling and compositor animations from the main thread. The compositor can independently:
- Handle scroll events and update scroll position
- Run transform/opacity animations
- Manage touch gestures (pinch-to-zoom, overscroll bounce)
This architecture is why Chrome can scroll smoothly even while JavaScript is busy — the compositor thread processes the scroll independently and recomposites with the new scroll offset. Other browsers followed the same architecture.
Measuring the Difference in DevTools
Rendering Tab
- Open DevTools → More tools → Rendering
- Enable "Paint flashing" — green overlays show repainted areas
- Enable "Layer borders" — orange borders show composited layers
With left animation: green flash on every frame (repainting).
With transform animation: no green flash (compositor only, no repaint).
Performance Panel
Record the animation and compare:
left: Style, Layout, Paint, and Composite events every frametransform: Only Composite events — Style, Layout, and Paint are absent
The Cost Hierarchy
Not all CSS properties are created equal when it comes to animation cost:
Cheapest → Most Expensive:
1. transform, opacity → Composite only (compositor thread)
2. color, background → Paint + Composite (main thread, no layout)
3. font-size, padding → Layout + Paint + Composite (main thread, full pipeline)
4. top/left + siblings → Layout (affecting many elements) + Paint + Composite
Paul Lewis's csstriggers.com catalogs every CSS property and which pipeline stages it triggers in each browser engine (Blink, Gecko, WebKit). Consult it when choosing animation properties.
- 1Only transform, opacity, filter, and backdrop-filter are compositor-only — they skip layout and paint entirely.
- 2Compositor-only animations run on the compositor thread, separate from the main thread. They stay smooth even during long JavaScript tasks.
- 3Animating left/top triggers layout recalculation every frame, even for absolutely positioned elements.
- 4GPU textures consume memory proportional to element dimensions × device pixel ratio. Each layer costs VRAM.
- 5Use DevTools Paint Flashing to identify non-compositor animations. Green = repaint happening.
- 6When layout change is only visual (no sibling reflow), use transform: scale() instead of width/height.
- 7The compositor thread also handles smooth scrolling and touch gestures independently of JavaScript.
Q: Explain why animating transform: translateX(100px) performs better than animating left: 100px. Be specific about the browser architecture involved.
A strong answer covers: the main thread vs compositor thread architecture, the rendering pipeline stages (style → layout → paint → composite), how left triggers layout recalculation and repaint on the main thread every frame, while transform only requires the compositor to reposition an existing GPU texture. Mention that compositor-only animations continue running during long JavaScript tasks because they are on a different thread. Bonus: explain GPU texture rasterization, the memory cost of layers, and implicit layer promotion for overlap stacking order resolution.