Transitions and animations — the properties that actually run smoothly

Not every animatable CSS property costs the same to animate — the browser's rendering pipeline has three distinct stages, and which stage a property triggers determines whether an animation runs at a silky 60fps or visibly stutters.

Intermediate

3 min read

transition vs animation: state-change vs a defined, repeatable sequence

/* transition — animates BETWEEN two states, triggered by something changing (:hover, a class toggle) */
.button { transition: background-color 0.2s ease; }
.button:hover { background-color: #1d4ed8; }
 
/* animation — a self-contained, named sequence, can run automatically and repeat */
@keyframes pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.05); }
}
.badge { animation: pulse 2s ease-in-out infinite; }

A transition needs an actual state change to trigger it — a hover, a class toggling, a JS-driven style change — and it only ever animates from the old value to the new one, once. An animation (paired with @keyframes) defines a self-contained sequence with multiple named waypoints, and can run on page load, repeat (infinite), and reverse, entirely independent of any external state change — the right tool for a loading spinner or an ambient pulse, where transition has nothing to "trigger" it.

The rendering pipeline's three stages, and which properties trigger which

Layout   → recalculates every affected element's SIZE and POSITION
           (triggered by: width, height, top, left, margin, padding, ...)
Paint    → recalculates PIXELS: colors, shadows, borders
           (triggered by: background-color, box-shadow, border-color, ...)
Composite → the GPU recombines already-painted LAYERS, no recalculation needed
           (triggered by: transform, opacity)

Layout is by far the most expensive stage — changing an element's width can force the browser to recompute the position of every sibling and descendant that depends on it, cascading outward. Paint is cheaper but still real work — repainting pixels for every affected area, every frame. Composite is dramatically cheaper: transform and opacity can often be handled entirely by the GPU, recombining pre-rendered layers without touching layout or paint at all — which is exactly why they're singled out as the properties safe to animate at a reliable 60fps even on modest hardware.

The practical rule: animate transform and opacity, avoid animating layout properties

/* Expensive — triggers LAYOUT on every frame of the animation */
.card:hover { width: 320px; margin-left: -10px; }
 
/* Cheap — the SAME visual effect, achieved with transform instead */
.card:hover { transform: scale(1.06); }

Both examples make the card appear larger, but the width/margin version forces a full layout recalculation on every single animation frame — visibly janky on lower-end devices, especially with several such elements animating simultaneously — while the transform: scale() version is handled almost entirely on the GPU compositor, smooth regardless of how complex the rest of the page's layout is. This is the single most impactful rule of thumb in performant CSS animation: whenever a transform or opacity equivalent achieves the same visual result as a layout- or paint-triggering property, prefer it.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What's the key difference between `transition` and `animation`?

2. Why are `transform` and `opacity` singled out as the properties safe to animate at 60fps?

3. Why is animating `width`/`margin-left` for a hover-grow effect worse than using `transform: scale()`?