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.
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
Three APIs can drive transform and opacity. Each takes a different route, but all three can land on the compositor thread when the element sits on its own layer and only those two properties change.
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, walk the trace through three gates before you call the change a win.
- 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 β the failure mode detailed in Hardware Acceleration Limits. See Rendering Performance Metrics and Tooling for wiring that before/after comparison into a budget.
Why Transform and Opacity Are the Cheap Pair
transform and opacity occupy a privileged place in the rendering pipeline because changing them does not invalidate layout or paint β it only changes how an already-painted texture is composited. When an element is on its own compositor layer, its content has been rasterized into a GPU texture once; animating transform reassembles that texture at a new position, scale, or rotation, and animating opacity reassembles it with a new alpha, both operations the GPU performs cheaply every frame. No box is re-measured, no pixel is re-drawn. That is the whole reason these two properties can animate at a full 60 or 120fps even while the main thread is completely blocked: the work happens on the compositor thread, which the main threadβs busyness cannot touch. The mechanism is explained end-to-end in why transform and opacity are GPU-accelerated.
Contrast this with the properties people reach for out of habit. Animating left/top changes the elementβs box position, which forces layout every frame; animating width/height forces layout plus a full repaint; animating box-shadow or background forces a repaint. Each of these re-enters the main-thread pipeline on every frame of the animation, competing with event handlers and script for the 16.6ms budget. The visual result can look identical β a card sliding in β but one implementation costs a compositor reassembly and the other costs a layout-and-paint pass sixty times a second. Choosing the compositor pair is the single highest-leverage animation decision, and it is almost always expressible: a slide is a translate, a grow is a scale, a fade is opacity.
/* β animates geometry β layout + paint every frame */
@keyframes slide-bad { from { left: 0; } to { left: 240px; } }
/* β
same motion via transform β compositor-only, no layout or paint */
@keyframes slide-good { from { transform: translateX(0); } to { transform: translateX(240px); } }
The translate-vs-position Trap and Its Limits
Because transform is compositor-only, the reflex to convert every animation to it is mostly correct β but there are edges. A transform: scale() scales the rasterized texture, which means text and sharp edges can blur if the layer was rasterized at the pre-scaled size; for large scale factors the engine may re-raster at the new size, reintroducing cost. Animating transform also does not change layout, so an element translated out from under the pointer still occupies its original box for hit-testing until layout catches up β occasionally surprising for interactive elements. And will-change: transform used to promote the element is a memory cost that should be added before the animation and removed after, not left permanently, as covered in layer promotion and composition.
The other limit is that the compositor fast path holds only while nothing forces the element back onto the main thread. If a non-compositable property animates alongside transform β a box-shadow that also changes, or a layout-affecting property sneaking into the keyframes β the whole animation drops back to per-frame main-thread work, and the transform no longer saves you. The engine composites the frame with whatever it has, and the mixed animation is only as fast as its slowest property. Keeping the keyframes pure β transform and opacity and nothing else β is what preserves the guarantee, and a Performance trace showing Composite Layers with a flat Main track is the proof it held.
Verifying the Fast Path Held
It is easy to write a transform animation and assume it is compositor-driven; confirming it is a two-minute check that catches the cases where it silently is not. Enable Paint flashing in the DevTools Rendering tab and run the animation: a genuinely compositor-only animation produces no green repaint rectangles, because reassembling a texture is not painting. If the element flashes green every frame, something is forcing a repaint β a non-compositable property in the keyframes, or an element that never got its own layer. Then record a Performance trace: the healthy signature is Composite Layers on the Compositor track with the Main track idle during the animation, versus Recalculate Style, Layout, or Paint firing per frame in the broken case.
This verification matters because the failure is invisible to the eye β a main-thread transform animation looks fine until the main thread gets busy, at which point it stutters exactly when smoothness matters most, during load or interaction. Building the Paint-flashing and trace check into your review of any new animation turns βit looked smooth on my machineβ into βit is provably on the compositor,β which is the only version of the claim that survives a slow device. The measurement habit is the same one the rendering performance metrics section applies to every optimisation: do not trust an animation is cheap because it looks cheap; confirm it in the tools.
Accessibility and the Reduced-Motion Contract
A transform/opacity animation being cheap does not make it always appropriate β motion can trigger vestibular discomfort, and the platform exposes a user preference for it. Wrapping non-essential motion in @media (prefers-reduced-motion: reduce) and shortening or removing it for users who ask is both a correctness and an inclusivity requirement, and it costs nothing on the performance side because it removes work. The pattern is to author the animation normally and then, inside the reduced-motion query, set the transition to none or replace a large translate with a simple opacity fade. Because the fade is still an opacity change, it stays on the compositor fast path while respecting the preference.
The performance angle worth noting is that reduced-motion is a chance to delete frames entirely: an animation you do not run is the cheapest possible animation, and honouring the preference means a meaningful fraction of your users pay zero animation cost. Treat the reduced-motion branch as the default-safe path and the full animation as the enhancement, and you get an experience that is both accessible and, for the users who opt out, strictly faster. This mirrors the broader discipline of the section β the cheapest work is the work you avoid β applied to the one axis where avoiding it is also the respectful choice. It is worth testing the reduced-motion branch explicitly, because it is easy to author the full animation, forget the query, and ship motion to users who asked for none; a quick toggle of the OS setting during review confirms the fallback actually engages.
Frequently Asked Questions
Why do only transform and opacity skip layout and paint?
Both properties operate on an elementβs already-rasterized GPU texture rather than its box geometry. transform maps to a 4x4 matrix the compositor multiplies against the existing texture, and opacity maps to an alpha blend applied at composite time. Neither changes the size, position, or pixels of any other element, so the browser can skip style, layout, and paint and update the frame on the compositor thread. See Why transform and opacity are GPU-accelerated for the architecture.
Should I always add will-change: transform to animated elements?
No. will-change promotes the element to its own compositor layer, which allocates a GPU texture that stays resident for as long as the declaration is present. Applied statically across many elements it causes compositor memory pressure and can regress TBT. Set it just before the animation starts and reset it to auto when the animation ends, as shown in the JavaScript example. Layer Promotion and Composition covers the full lifecycle.
How do I confirm an animation is actually running on the compositor?
Record a Performance trace during the animation and inspect the Main thread lane. If you see no Layout or Paint events across the animationβs frames and only Composite Layers activity, the animation is compositor-only. Any recurring Recalculate Style or Layout event per frame means a layout-triggering property is still being animated.
Can I animate top and left if I need pixel-precise positioning?
Prefer transform: translate3d() instead β it accepts the same pixel values but runs on the compositor rather than forcing a layout recalculation each frame. Animating left or top re-runs layout, paint, and composite on every frame and burns roughly 18 recalculations across a 300ms transition at 60fps. Reserve left/top changes for one-off placements outside an animation loop.
Does the Web Animations API guarantee compositor-only execution?
Not by itself. element.animate() runs on the compositor only when the animated keyframes touch just transform and opacity and the element is on its own layer. Mixing in a layout-affecting property such as width or margin forces the whole animation back onto the main thread, so keep WAAPI keyframes limited to the two compositor-friendly properties.
Related Guides
- Why transform and opacity are GPU-accelerated β the compositor-architecture reason these two properties bypass layout and paint.
- Animating transforms without layout thrash β driving transforms through a JS loop without re-triggering layout.
- Layer Promotion and Composition β how and when elements get their own compositor layer, and the memory cost.
- Hardware Acceleration Limits β the will-change and VRAM failure modes that regress TBT.
- Compositing and GPU Acceleration β the parent guide covering the whole compositing stage.