Concept
A CSS transition animates a property smoothly between its old and new value whenever that value changes, triggered externally (a :hover, a class toggle from JS, a :focus state), not self-initiated. This is the key distinction from @keyframes animations (covered in the next topic), which run independently and can loop, have multiple stages, and start without an external trigger.
The four transition properties
.button {
transition-property: background-color, transform;
transition-duration: 0.2s;
transition-timing-function: ease-out;
transition-delay: 0s;
/* shorthand, what you'll write almost always */
transition: background-color 0.2s ease-out, transform 0.2s ease-out;
}
.button:hover {
background-color: #4338ca;
transform: translateY(-2px);
}transition-property, which properties to animate (allworks but is a performance and specificity footgun, covered below).transition-duration, how long the transition takes.transition-timing-function, the easing curve (covered below).transition-delay, wait time before starting.
Timing functions, the difference between motion feeling natural or robotic
transition-timing-function: linear; /* constant speed, feels mechanical, rarely what you want for UI */
transition-timing-function: ease; /* default, slow start, fast middle, slow end */
transition-timing-function: ease-in; /* slow start, fast end, good for elements leaving */
transition-timing-function: ease-out; /* fast start, slow end, good for elements entering/appearing */
transition-timing-function: ease-in-out; /* slow start AND end, good for elements moving in place */
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* custom curve, e.g. for a "bounce" overshoot */Real UI motion almost never uses linear, physical objects don't move at constant velocity, they accelerate and decelerate, and linear motion reads as distinctly artificial/robotic to the eye even when the difference is subtle. ease-out is generally the best default for elements appearing/responding to interaction (a button press, a tooltip appearing) since it front-loads the perceived responsiveness.
transition: all, convenient, but a real cost
/* Convenient but transitions EVERY animatable property that changes, including ones you didn't intend */
.card { transition: all 0.2s; }all is tempting shorthand but has two real costs: it animates properties you didn't intend to (a layout shift caused by something unrelated now animates too, potentially looking like a bug), and it's more expensive for the browser to watch every property for changes rather than a specific known list. List the specific properties you actually intend to animate.
Only transform and opacity animate on the compositor thread
/* Cheap, GPU compositor thread, no layout/paint recalculation needed */
.card { transition: transform 0.2s, opacity 0.2s; }
.card:hover { transform: scale(1.05); opacity: 0.9; }
/* Expensive, triggers layout recalculation on every frame of the transition */
.card { transition: width 0.2s, top 0.2s; }
.card:hover { width: 300px; top: 10px; }This is the single most important performance fact about CSS animation: transform and opacity are the only properties that can be animated purely on the GPU compositor thread, without triggering layout (reflow) or paint on every frame. Animating width, height, top/left, margin, or similar box-model/positional properties forces the browser to recompute layout on every single frame of the transition, on complex pages or lower-end devices, this is the direct cause of janky, dropped-frame animations. Wherever possible, express motion in terms of transform: translate()/scale()/rotate() instead of animating layout-affecting properties directly (e.g. transform: translateX(20px) instead of animating left).
Detecting when a transition finishes
element.addEventListener("transitionend", (e) => {
console.log(`${e.propertyName} finished transitioning`);
});Useful for cleanup after a transition completes, removing an element from the DOM only after its fade-out transition finishes, for instance, rather than yanking it out instantly while still visually mid-animation.
Respecting prefers-reduced-motion
@media (prefers-reduced-motion: reduce) {
* {
transition-duration: 0.01ms !important;
}
}Covered in depth in the Accessibility topic, some users have vestibular disorders where motion genuinely causes physical discomfort, not just an aesthetic dislike. Respecting this media query is a real accessibility requirement, not an optional nicety.
Common Mistakes
1. Using transition: all by default
Covered above, animates unintended property changes and is measurably more expensive than listing specific properties.
2. Animating width/height/top/left/margin instead of transform
/* Janky on complex pages, triggers layout every frame */
.menu { left: -300px; transition: left 0.3s; }
.menu.open { left: 0; }
/* Smooth, GPU compositor only */
.menu { transform: translateX(-300px); transition: transform 0.3s; }
.menu.open { transform: translateX(0); }This single substitution (left/top → transform: translate()) is the most impactful CSS animation performance fix available, and it's almost always a drop-in replacement for the common case of sliding/moving an element.
3. Forgetting the transition only applies to the property change, not the initial render
/* Element appears already in its "hover" state on first paint, no transition plays,
because there was no PRIOR value to transition FROM on initial render */Transitions only animate a change from one value to another. If JS adds a class with the target styles at the same moment the element is inserted into the DOM (no separate paint in between), the browser may not register a "from" state to animate from, and the element just appears instantly in its final state. This is why entrance animations sometimes require a requestAnimationFrame delay or a brief timeout between insertion and class toggle.
4. Not respecting prefers-reduced-motion
Especially relevant for larger, more dramatic transitions (page transitions, big layout shifts), smaller UI micro-interactions are less commonly an issue, but anything with real motion scale should respect the preference.
5. Transitioning height: auto and expecting it to animate smoothly
/* Doesn't work as expected, you can't transition TO/FROM `auto`, only between two explicit values */
.accordion { height: 0; transition: height 0.3s; }
.accordion.open { height: auto; }CSS transitions require two concrete values to interpolate between, auto isn't a fixed value the browser can animate toward smoothly (the common workaround is transitioning max-height to a large-enough fixed value, or using grid-template-rows: 0fr → 1fr which does animate smoothly as of modern browser support, or measuring the actual content height via JS and transitioning to that specific pixel value).
Best Practices
- List specific properties in
transition, notall. - Prefer
transform/opacityfor any animation where a layout-affecting alternative exists, this is the highest-leverage CSS animation performance practice. - Use
ease-outas a sensible default for interactive/appearing elements; reservelinearfor cases where constant velocity is genuinely correct (some loading indicators). - Respect
prefers-reduced-motion, especially for anything beyond small micro-interactions. - Use the trick (or JS-measured height) for animating to/from height, since transitioning directly to/from doesn't work.
Further Resources
- MDN, CSS transitions
- MDN,
transition-timing-function - web.dev, Animations Guide (compositor-only properties)
- cubic-bezier.com, visual easing curve editor.
- CSS-Tricks, Using CSS Transitions on Auto Dimensions
