An animation can be smooth on your dev laptop and janky on your user's mid-range phone. The difference is never aesthetics but WHICH property you animate. Two are free, a dozen cost dearly — understanding why requires knowing the browser's rendering pipeline, and that knowledge pays forever.
The pipeline: layout → paint → composite
Every frame (ideally one every 16.6ms for 60fps), the browser runs up to three phases depending on what changed:
- Layout: compute geometry — position and size of every element.
- Paint: draw pixels into layers.
- Composite: stack pre-rasterized layers, on GPU.
The complete golden rule: animating properties touching only composite is cheap; ones forcing layout are brutally expensive.
/* EXPENSIVE: every frame recalculates layout AND paint, dragging neighbors */
.bad { transition: width .3s, margin .3s, top .3s; }
/* CHEAP: composites only; no layout, no paint */
.good { transition: transform .3s, opacity .3s; }
Why does width drag neighbors? Changing its width reflows the whole flow: siblings displaced, text re-wrapped, containers resized... In a large DOM a single layout change can consume the entire frame budget. transform moves the element as an independent GPU layer without touching flow — visually equivalent for move/scale/rotate.
The canonical substitutions
| Want | Don't use | Use |
|---|---|---|
| Move | top/left/right/bottom |
transform: translate() |
| Scale | width/height/font-size |
transform: scale() |
| Show/hide | display/visibility alone |
opacity (+ visibility at end) |
| Background color | heavy animation of it | opacity over layer with target color |
| Growing shadows | direct box-shadow |
pre-rendered pseudo-element + opacity |
The shadow case deserves detail since it's a common trap: animating box-shadow forces repaint every frame. The professional technique renders the final shadow in an invisible ::after from the start and animates only its opacity:
.card { position: relative; }
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 12px 32px rgba(0,0,0,.25);
opacity: 0;
transition: opacity .3s;
}
.card:hover::after { opacity: 1; }
One initial (invisible) repaint, then pure composition.
will-change: promise before promising
will-change: transform warns the browser that property will change, letting it prepare (promote to its own layer) BEFORE animation starts. Avoids first-frame hitch — at a cost: each promoted layer consumes GPU memory.
Honest usage rules:
- Apply only to elements about to animate repeatedly (a visible carousel, a drawer).
- NEVER apply globally to dozens of elements "just in case": wasted memory plus layer management overhead.
- Remove when animation ends if it's one-off (JS:
element.style.willChange = 'auto').
And mind the practical limit: too many large layers exhaust GPU textures causing compositor crashes on old phones. Layers yes, in moderation.
How to diagnose
DevTools → Rendering → enable Paint flashing (paints green everything repainted: if your animation flashes green constantly, you're repainting), Layout Shift Regions (blue = reflows). The Performance panel records a session showing exactly which phase eats your frame budget. Seeing "Recalculate Style" and "Layout" dominating every 16ms? Return to the substitution table.
Accessibility: prefers-reduced-motion
For users with vestibular disorders (motion sickness), big animations aren't annoying — they're nauseating. The media query respects their system setting:
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Or better, substitute soft opacity transitions instead of killing them. It's baseline accessibility like contrast: minimal cost, real impact.
Practice with live examples
Every pattern in this article is implemented in our spinners: the CSS loader generator emits six animation types built exclusively with transform and opacity, namespaced keyframes and staggered delays — copy them as clean-animation reference. For each spinner's inner mechanics, see the CSS spinners guide, and if your animation involves blur, check backdrop-filter's real costs in the glassmorphism guide.
FAQ
60fps or 120fps? Reasonable goal: hold steady 60. On ProMotion screens the target rises to 120 (8.3ms/frame budget) — another reason to stick with transform/opacity.
Is animating SVG different? Same principles apply, nuance included: SVG geometric attributes (cx, r) force graph layout; CSS transform on the node remains the cheap path.
Do libraries (GSAP, Framer Motion) already optimize? Good ones do — they prioritize transforms and batch updates. But none can convert animated width into composite-only: property choice remains yours.
Generate production-ready clean animations with our CSS Loader Generator, free and right in your browser.