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. Picking the right properties is the cheapest win in Compositing and GPU Acceleration: it lets the compositor do the work the main thread would otherwise repeat.

Pipeline stages skipped by transform and opacity versus layout-triggering properties Layout-triggering properties run the full style, layout, paint, composite pipeline each frame, while transform and opacity skip straight to the composite stage. left / width (per frame) Style Layout Paint Composite transform / opacity (per frame) Style (skip) Layout (skip) Paint (skip) Composite only

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. For the concrete how-to of driving these properties through a JS loop without re-triggering layout, see Animating transforms without layout thrash.

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. The same property choice matters under scroll-linked motion — see Scroll and Input Performance for keeping those gestures off the main thread.

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 Layout or Paint events during the animation. Only Composite Layers on 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 — the failure mode detailed in Hardware Acceleration Limits. See Rendering Performance Metrics and Tooling for wiring that before/after comparison into a budget.