Transform and Opacity Best Practices
Why These Properties Are Different
Animating width, height, top, left, margin, or padding forces the browser to recalculate layout geometry on every frame. That work runs on the main thread, competes with JavaScript execution, and directly consumes the 16.6ms frame budget. A 300ms transition on left causes ~18 layout recalculations at 60fps.
transform and opacity are handled differently. When an element is on its own compositor layer, changes to these two properties are applied by the compositor thread directly to the existing GPU texture — no layout recalculation, no paint, no main-thread involvement. The compositor interpolates the matrix and submits the updated frame.
/* ❌ Triggers layout + paint every frame */
.element {
transition: left 0.3s ease, width 0.3s ease;
}
/* ✅ Compositor-only: layout and paint run once (for the initial state),
then the compositor handles all subsequent frames */
.element--optimized {
transition: transform 0.3s ease, opacity 0.3s ease;
will-change: transform, opacity;
}
For the architectural reason this works, see Why transform and opacity are GPU-accelerated.
Trace Analysis
Profiling reveals the difference immediately. Record a Performance trace during the animation. In the Main thread lane:
[Main Thread — layout-triggering animation]
├─ Layout (Recalculate Style) ....... 12.4ms — budget exceeded
├─ Paint (Rasterize Layers) .......... 6.8ms
└─ Composite Layers .................. 0.9ms
Frame total: 20.1ms — DROPPED
[Main Thread — transform/opacity animation]
├─ (no Layout or Paint events)
└─ Composite Layers .................. 0.9ms
Compositor Thread: Update Transform Matrix 0.2ms
Frame total: 1.1ms — WELL WITHIN BUDGET
The layout-triggering case drops frames because 12.4ms of layout plus 6.8ms of paint leaves no room for input handling. The compositor-only case consumes under 2ms of total wall time.
Implementation
CSS transitions
Prefer CSS transitions and animations for visual state changes. The browser applies compositing optimisations automatically when you use transform and opacity.
JavaScript animations
class CompositorAnimation {
constructor(element) {
this.el = element
this.start = null
this.duration = 300
}
animate(timestamp) {
if (!this.start) this.start = timestamp
const progress = Math.min((timestamp - this.start) / this.duration, 1)
// Both properties stay on the compositor thread
this.el.style.transform = `translate3d(${progress * 100}px, 0, 0)`
this.el.style.opacity = String(1 - progress)
if (progress < 1) {
requestAnimationFrame((ts) => this.animate(ts))
} else {
// Release the compositor layer once the animation is done
this.el.style.willChange = 'auto'
}
}
start() {
this.el.style.willChange = 'transform, opacity'
requestAnimationFrame((ts) => this.animate(ts))
}
}
The will-change: auto cleanup at the end is important. Static will-change declarations keep the GPU texture allocated indefinitely, consuming VRAM even when the element is not animating. For the full lifecycle pattern and memory implications, see Layer Promotion and Composition and Hardware Acceleration Limits.
Web Animations API
For complex sequences, the Web Animations API gives fine-grained control while keeping the animation on the compositor when possible:
element.animate(
[
{ transform: 'translateX(0)', opacity: 1 },
{ transform: 'translateX(100px)', opacity: 0 },
],
{ duration: 300, easing: 'ease', fill: 'forwards' },
)
The browser determines whether the animation can run entirely on the compositor. If it can (only transform and opacity are changing and the element is on its own layer), it will.
Validation
After switching from layout-triggering to compositor-only animations:
- Performance trace: No
LayoutorPaintevents during the animation. OnlyComposite Layerson the compositor thread. - Frame rate: Stable 60fps (or 120fps on high-refresh displays) with no dropped frames during the transition.
- INP: Remains below 200ms even during the animation, because the main thread is free to handle input.
Run Lighthouse CI before and after. A regression in TBT after switching to transform/opacity usually indicates a will-change declaration left in place on many elements, allocating GPU memory unnecessarily and causing compositor memory pressure.