13 — Animations & Transitions: The Compositor Pipeline
The render pipeline is: style → layout → paint → composite. Animating transform and opacity skips layout and paint — they're compositor-only (GPU). Animating width/height/top/left triggers layout every frame → janky. This is the single most important performance rule in CSS.
Transitions — smooth property interpolation
.button {
background: blue;
transition: background 0.3s ease, transform 0.2s ease; /* list SPECIFIC properties */
}
.button:hover { background: darkblue; transform: scale(1.05); }
/* ⚠️ transition: all is a footgun — animates unexpected properties (background-image, etc.) */
Timing Functions — cubic-bezier for overshoot
:root {
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* y>1 → overshoots target, settles (bounce) */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* fast start, slow settle */
}
.modal { transition: transform 0.3s var(--ease-spring); }
/* steps(n, start|end): discrete steps — sprite animation, loading bars */
.sprite { transition: background-position 0.5s steps(8, end); }
The Performance Rule — compositor-only properties
Property Cost Animate?
transform Compositor (GPU) ✅ 60fps
opacity Compositor (GPU) ✅ 60fps
filter Compositor (mostly) ✅
color/bg-color Paint ⚠️ OK
box-shadow Paint ⚠️ (use pseudo-element opacity instead)
width/height Layout ❌ expensive
top/left/margin Layout ❌ expensive
Anti-pattern: animating height triggers layout
/* ❌ height triggers layout every frame → janky. And height:auto can't interpolate (auto isn't a length). */
.menu { height: 0; overflow: hidden; transition: height 0.3s ease; }
.menu.open { height: 200px; } /* fixed value — breaks if content changes */
/* ✓ transform: scaleY (compositor-only, GPU, no layout). transform-origin: top anchors it. */
.menu { transform: scaleY(0); transform-origin: block-start; transition: transform 0.3s ease; }
.menu.open { transform: scaleY(1); }
/* ✓ grid-template-rows: 0fr → 1fr (animates to content height, interpolable, fits any content) */
.menu-wrap { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease; }
.menu-wrap.open { grid-template-rows: 1fr; }
.menu { overflow: hidden; } /* clip the collapsing content */
/* Browser support: grid-template-rows 0fr↔1fr transition: Chrome 107+, Safari 16+, Firefox 66+. */
@keyframes Animations
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.spinner { animation: spin 1s linear infinite; }
.pulse { animation: pulse 2s ease-in-out infinite; }
/* animation: name duration timing-function delay iteration-count direction fill-mode play-state */
/* animation-fill-mode: forwards → retains the END state after the animation (default: none → snaps back) */
/* negative animation-delay: -1s on a 2s animation starts it at the 1s mark (already halfway) → staggered */
will-change — the GPU hint (use sparingly)
.modal { will-change: transform, opacity; } /* hint: promote to a GPU layer NOW */
.modal.closed { will-change: auto; } /* remove after → frees GPU memory */
/* ⚠️ Each will-change element gets a GPU layer (RAM). * { will-change: transform; } = OOM on mobile. */
/* Add will-change just before the animation, remove after. Never leave it permanently. */
prefers-reduced-motion — mandatory accessibility
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
💡 Tips & Tricks
- Idiom:
cubic-bezier(0.34, 1.56, 0.64, 1)for overshoot/bounce — the y-value >1 makes the animation go past the target and settle. Playful, spring-like. - Idiom:
animation-fill-mode: forwards(orboth) to retain the end state — without it, the element snaps back to pre-animation state.bothalso applies the start state during the delay. - Idiom: negative
animation-delayfor staggered animations that appear already in progress —animation-delay: -1son a 2s animation starts it halfway through. - Idiom:
steps(8)for sprite-sheet animation — 8 discrete steps cycle through a sprite sheet, the classic CSS sprite technique. - Idiom: animate
transform/opacity(GPU), neverwidth/height/top/left(layout).transform: translateX()for position,scale()for size.
⚠️ Edge Cases & Gotchas
transition: allis a footgun: animates every property change, including non-interpolable ones (background-image). List specific properties.transitiondoesn't work ondisplay:display: none → blockcan't transition. Useopacity+visibility, or a transform.height: autocan't transition:autoisn't a length → not interpolable. Usetransform: scaleY()orgrid-template-rows: 0fr → 1fr.animation-fill-mode: none(default) snaps back: after the animation, the element returns to its pre-animation state. Useforwardsto keep the end state.will-changeon too many elements wastes memory: each gets a GPU layer. Use only for elements about to animate, remove after.@keyframeswithoutfrom/0%uses the element's current state: if you only specifyto { }, the animation starts from the computed style. Useful for "animate to this state."transitionreverses from the current point: if a property changes and changes back mid-transition, the transition reverses from the current (mid-animation) state, not the start.
🧠 Spot the Bug
.menu { height: 0; overflow: hidden; transition: height 0.3s ease; }
.menu.open { height: 200px; }
Answer
Animating height triggers layout every frame — the browser recomputes the layout of .menu and all descendants. On mobile (slower CPUs), this drops frames → janky. Also, height: 200px is a fixed value — if the content changes, it breaks, and height: auto can't transition (auto isn't interpolable). Fix: transform: scaleY(0) → scaleY(1) (compositor-only, GPU, 60fps) or grid-template-rows: 0fr → 1fr (animates to content height, interpolable). The lesson: never animate width/height/top/left — use transform or grid-template.