Why are transform and opacity preferred for performant animations over properties like width or top?
Advertisement
🧩 Scenario
Architecture Walkthrough
The Rendering Pipeline
To render a frame, the browser runs a sequence of stages, and each stage depends on the ones before it:
- Style — compute which rules apply and resolve the final property values for every element.
- Layout (reflow) — compute geometry: the size and position of every box. Because boxes affect one another, changing one element's geometry can require recalculating a large part of the document.
- Paint — fill in pixels: colours, text, borders, shadows, images, into one or more paint records.
- Composite — assemble the painted layers in the correct order, applying transforms and opacity, and hand the result to the screen.
The cost sits heavily at the front. Layout is expensive because it is interdependent, paint is expensive because it is proportional to pixel area, and compositing is comparatively cheap because it is mostly the GPU moving and blending existing bitmaps.
What Each Property Triggers
Every animatable property enters the pipeline at some stage, and everything from that stage onward has to rerun.
width, height, top, left, margin, padding, font-size, and border-width all change geometry, so they trigger layout, then paint, then composite. Every frame of the animation pays for all three. Worse, layout is not local: changing one element's width can reflow its siblings, its ancestors' heights, and anything positioned relative to them, so the cost scales with the surrounding document rather than with the animated element.
background-color, color, box-shadow, border-radius, visibility, and outline do not change geometry, so they skip layout and trigger paint, then composite. Cheaper, but paint cost is proportional to the area being repainted, and box-shadow in particular is expensive to rasterise.
transform and opacity change neither geometry nor the painted content. They only change how an already-painted layer is positioned, scaled, or blended, so they trigger composite only. filter is often in this group as well, though its cost varies by function.
The Compositor Thread Is the Real Advantage
Skipping layout and paint is the smaller half of the benefit. The larger half is which thread does the work.
Style, layout, and paint run on the main thread, the same thread that executes JavaScript. Compositing runs on a separate compositor thread, typically with GPU assistance. When an animation only needs compositing, the browser can hand the whole thing to the compositor and it continues at full frame rate even while the main thread is blocked by a long task: parsing a large JSON payload, hydrating a component tree, running a heavy reduce.
That is why a transform animation stays smooth during a page's most expensive moment while a left animation stutters at exactly that moment. It is not that left is slightly more expensive per frame; it is that left is competing with JavaScript for the same thread and loses.
Layer Promotion Is Not Free
The common overcorrection is to assume transform and opacity are costless and to promote everything.
Compositing requires the element to live on its own layer, which means its own bitmap in GPU memory. A full-screen element at a 3x device pixel ratio is a large allocation. Promote dozens of elements and memory pressure becomes the bottleneck, which on a low-end mobile device can be worse than the layout you avoided.
will-change: transform tells the browser to promote in advance, which avoids a hitch at the animation's start. But it promotes permanently while the declaration applies, so leaving it in a stylesheet on a frequently used component is a standing memory cost. Apply it shortly before the animation and remove it after, or omit it entirely and let the browser's own heuristics handle promotion, which they usually do well for actual animations.
The old translateZ(0) and backface-visibility: hidden hacks did the same thing before will-change existed and carry the same caveat, plus they create a stacking context, which is a common source of unrelated z-index bugs.
Converting Layout Animations to Transforms
Most layout animations have a transform equivalent.
Movement: replace left/top with translateX/translateY. Set the final position statically and animate the transform from an offset, so the resting state needs no transform at all.
Size: replace width/height with scaleX/scaleY, accepting that scaling distorts children and text, which is why it suits bars and backgrounds more than content boxes. When true size animation is needed, the FLIP technique measures the start and end geometry and expresses the difference as a transform, which is how most high-quality layout animations are built.
Appearance and disappearance: use opacity rather than display or visibility for the fade, with transition-behavior: allow-discrete when display must also change.
Collapsing panels: animating height from 0 to auto is a classic layout animation. grid-template-rows: 0fr to 1fr and the interpolate-size: allow-keywords property are the modern approaches; before those, the common workaround was a max-height transition with an approximate ceiling.
Measuring Rather Than Guessing
The Performance panel shows the pipeline stages per frame, so a purple layout bar or a green paint bar during an animation identifies the problem directly. The Rendering panel's paint flashing highlights repainted regions, and the Layers panel shows which elements have been promoted and how much memory each layer holds. Those three tools answer "is this animation compositor-only" definitively, which is better than reasoning from a property list.
Key Code Explained
/* LAYOUT + PAINT + COMPOSITE every frame, and reflows siblings too */
.panel-slow {
left: 0;
transition: left 300ms ease;
}
.panel-slow.is-open {
left: 300px;
}
/* COMPOSITE only — runs on the compositor thread */
.panel-fast {
transform: translateX(0);
transition: transform 300ms ease;
}
.panel-fast.is-open {
transform: translateX(300px);
}
/* Same distinction for size: width reflows, scale composites */
.bar-slow {
width: 0;
transition: width 400ms ease; /* layout every frame */
}
.bar-fast {
transform: scaleX(0);
transform-origin: left;
transition: transform 400ms ease; /* composite only */
}
/* PAINT + COMPOSITE: no layout, but repaint cost scales with area */
.card {
transition: background-color 200ms ease, box-shadow 200ms ease;
}
/* box-shadow is one of the more expensive things to rasterise */
/* The pair that is compositor-friendly by design */
.toast {
opacity: 0;
transform: translateY(8px);
transition: opacity 200ms ease, transform 200ms ease;
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
/* will-change: apply before, REMOVE after. Not a permanent declaration. */
.drawer.is-animating {
will-change: transform;
}
/* Leaving will-change on a component permanently costs GPU memory
for every instance, whether or not it is animating. */
/* Modern approach to the height: 0 -> auto problem */
.accordion {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 250ms ease;
}
.accordion.is-open {
grid-template-rows: 1fr;
}
/* Always pair motion with the reduced-motion guard */
@media (prefers-reduced-motion: reduce) {
.panel-fast,
.toast {
transition-duration: 0.01ms;
}
}
The .panel-slow versus .panel-fast pair is the whole answer in six lines. Both move an element 300px over 300ms and look identical when nothing else is happening. The difference appears under load: the left version runs style, layout, paint, and composite on the main thread every frame, competing with JavaScript, while the translateX version is handed to the compositor and keeps its frame rate regardless.
The .bar-slow and .bar-fast pair shows the same principle for size, along with its caveat. scaleX composites but distorts anything inside the element, which is fine for a progress bar and wrong for a card containing text. That limitation is why FLIP exists for genuine layout transitions.
Tradeoffs
| Property | Pipeline stages | Thread | Cost driver |
|---|---|---|---|
width, height, top, left, margin | Layout → Paint → Composite | Main | Document complexity around the element |
font-size, padding, border-width | Layout → Paint → Composite | Main | Same, plus text re-shaping |
background-color, color, border-radius | Paint → Composite | Main | Repainted pixel area |
box-shadow | Paint → Composite | Main | Expensive rasterisation |
transform, opacity | Composite | Compositor | GPU layer memory |
filter | Usually Composite | Compositor | Varies by function |
What Interviewers Actually Check
- Whether you can name the pipeline stages in order and place properties into them
- Whether you identify the compositor thread as the main advantage, not just skipping layout
- Whether you know layer promotion has a real memory cost
- Whether you use
will-changeas a temporary hint rather than a permanent declaration - Whether you can convert a
leftorwidthanimation into a transform equivalent
Follow-Up Questions
- What is the FLIP technique, and how does it animate a genuine layout change using only transforms?
- Why does
box-shadowcost more to paint thanbackground-color, and what is the cheaper alternative for an animated shadow? - What does
contain: layoutdo for the cost of animating a layout property inside a subtree? - How do
interpolate-size: allow-keywordsandcalc-size()change the height-to-auto problem? - How would you use the Performance and Layers panels to prove an animation is compositor-only?
Common Candidate Mistakes
- Animating
width,height,top, orleftand attributing the resulting jank to the framework or the device, when the animation is running layout on the main thread every frame - Treating
transformandopacityas free, ignoring that compositing allocates a GPU bitmap per promoted layer and that memory pressure has its own cost - Leaving
will-changein a stylesheet permanently, which promotes every instance of a component whether or not it is animating - Reaching for
leftandtopto move an element whentranslateproduces the same visual result without touching layout - Forgetting that animating a layout property on one element reflows its siblings and ancestors, so the cost scales with the surrounding document rather than the animated element
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you name the four pipeline stages in order?
- Can you say which stages
width,top,background-color,transform, andopacityeach trigger? - Can you explain what running on the compositor thread buys you beyond skipping layout?
- Can you explain the memory cost of layer promotion and how
will-changeshould be used? - Can you convert a
top/leftorwidthanimation into a transform equivalent?
Summary
The browser renders a frame in four stages: style, layout, paint, and composite. Each property enters at some stage and forces everything after it to rerun. Geometry properties such as width, height, top, and left enter at layout, so every animated frame pays for layout, paint, and composite, and because layout is interdependent, one element's change can reflow its siblings and ancestors. Paint-only properties such as background-color and box-shadow skip layout but still cost in proportion to the repainted area. transform and opacity change neither geometry nor painted content, so they only require compositing.
The decisive advantage is which thread does the work. Style, layout, and paint run on the main thread alongside JavaScript, while compositing runs on a separate compositor thread with GPU assistance. A compositor-only animation therefore keeps its frame rate even while the main thread is blocked by a long task, which is precisely when a left animation visibly stutters. The difference is not a small per-frame saving; it is whether the animation competes with JavaScript at all.
Compositing is not free, though. Each promoted element needs its own GPU bitmap, so promoting many or very large elements trades layout cost for memory pressure, which can be worse on low-end devices. will-change should be applied shortly before an animation and removed afterwards rather than left in a stylesheet permanently. In practice, replace left/top with translate, replace width/height with scale where distortion is acceptable, use FLIP for genuine layout transitions, reach for grid-template-rows: 0fr to 1fr for collapsing panels, and confirm the result in the Performance and Layers panels rather than reasoning from a property list alone.
Are transform and opacity always free?
No. They avoid layout and paint, but compositing still costs GPU memory per layer. Promoting many elements, or very large ones, can be slower than the layout you avoided.
Should I add will-change to everything I animate?
No. will-change promotes the element to its own layer permanently, consuming memory. Apply it shortly before the animation and remove it afterwards, or omit it and let the browser decide.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement