“Only animate transform and opacity” is the most repeated piece of advice in web animation, and one of the least explained. Taken on faith it becomes cargo cult. People promote elements onto their own layer for no reason, or avoid animating perfectly cheap properties because they are not on the approved list.
The rule is real. Understanding the mechanism tells you when it applies and when it does not.
The three stages a frame goes through
To put a frame on screen, the browser can do up to three things:
Layout: compute where every element sits and how large it is. Changing width, height, top, margin, or padding invalidates geometry, and because layout is a tree, changing one element’s size can force recomputation of its siblings, its parent, and everything after it in flow. This is the expensive one, and its cost scales with how much of the page is affected.
Paint: fill in pixels. Colours, shadows, borders, text, images. Changing background-color, box-shadow, or color means rasterising the affected regions again. Cheaper than layout, but still real work proportional to area.
Composite: take already-painted layers and arrange them: position, scale, rotate, blend, apply opacity. This is the cheap stage. The pixels already exist; the compositor is just placing them.
Every property you animate enters this pipeline at some point, and everything from that point on has to run again, sixty times a second.
Where transform and opacity enter
transform and opacity are special because they enter at the composite stage. They skip layout and paint entirely.
A translated element occupies the same layout space it always did. transform does not affect flow, which is exactly why it does not trigger layout. And a scaled or rotated element does not need repainting, because the compositor can transform the existing raster.
opacity is the same story: blending an already-painted layer against what is behind it is a compositor operation.
That is the entire reason for the rule. Not that these properties are magic, but that they are the only two commonly-animated ones that require no layout and no paint.
Which is why “just avoid layout” is not enough
A common half-fix is to animate left instead of width, reasoning that moving is cheaper than resizing. On a positioned element, left does not trigger full document layout, but it still triggers paint, every frame, for the element and anything it overlaps.
transform: translateX() triggers neither. Same visual result, one pipeline stage instead of two.
/* Paints every frame */
.thing { left: 0; transition: left 300ms; }
.thing.moved { left: 200px; }
/* Composites every frame */
.thing { transform: translateX(0); transition: transform 300ms; }
.thing.moved { transform: translateX(200px); }
The 16.7ms budget
At 60fps you have about 16.7 milliseconds per frame for everything: your JavaScript, layout, paint, composite, plus the browser’s own overhead. On a 120Hz display it is 8.3ms.
That budget is why the distinction matters so much. A layout pass on a complex page can eat several milliseconds on a fast machine, and considerably more on a mid-range phone with a fraction of the CPU. Miss the budget and the frame is dropped: the animation does not slow down gracefully, it stutters.
Composite-only animations can also run off the main thread. This is the part people underestimate. A transform animation keeps going smoothly even while JavaScript is busy. The compositor is a separate thread and does not care that you are parsing a large JSON response. A left animation, needing paint on the main thread, judders whenever the main thread is blocked.
That is often the real-world difference: not “slightly faster” but “keeps working under load.”
will-change, and why to use it sparingly
will-change tells the browser to promote an element to its own compositor layer ahead of time, so it does not have to do that work at the moment the animation starts.
.panel { will-change: transform; }
It is genuinely useful for eliminating a hitch on the first frame of an expensive animation. It is also the most misused property in this area.
Every promoted layer costs GPU memory, and the cost is proportional to the element’s area in device pixels. A full-screen layer on a high-DPI phone is several megabytes. Promote a dozen and you can exhaust memory badly enough to make everything worse, the exact opposite of the intent.
Two rules:
- Apply it just before the animation, remove it after. A permanent
will-changein a stylesheet is a permanent cost. - Do not apply it to many elements. If you find yourself putting it on a list, you are doing harm.
Browsers already promote elements they see animating a compositor property. will-change is for cases where the animation starts suddenly and you cannot afford the promotion hitch. It is not a general speed-up.
When the rule does not apply
Three cases where reflexively avoiding non-composited properties is wrong.
Small, isolated, infrequent animations. A colour transition on a button hover paints a tiny area, once. The pipeline stage matters far less than the area affected. Nobody has ever shipped a slow interface because of a 40×120px background-color transition.
When it changes the meaning. Scaling a card with transform: scale() scales its text and borders too: 1px borders become blurry, text renders at the wrong optical size. If you need a card to genuinely grow while its contents stay crisp, you need layout, and the correct move is to accept the cost and limit how many elements do it at once.
Non-visual work dominates. If your animation stutters because a fetch handler is parsing 4MB of JSON on the main thread, switching from left to transform will not save you. Profile before optimising. The property is often not the bottleneck.
The practical list
Composite-only, animate freely:
transform:translate,scale,rotate,skewopacityfilterandbackdrop-filter: usually GPU-accelerated, but genuinely expensive per-pixel; test on real hardware
Paint but not layout, fine in moderation:
background-color,color,border-colorbox-shadow,border-radiusvisibility
Trigger layout, so animate deliberately, and only a few at a time:
width,height,padding,margin,border-widthtop,right,bottom,leftfont-size,line-heightgrid-template-rows: unavoidable for height animation, and acceptable for one or two elements
The version worth remembering
Prefer transform and opacity because they skip layout and paint and can run off the main thread. Reach for will-change rarely and remove it afterwards. And when a layout-triggering property is genuinely the right tool, use it on as few elements as possible rather than contorting the design to avoid it.
The goal is a frame that fits in its budget, not a purity test.