CSS Transitions

A CSS transition lets an element change smoothly from one style to another instead of the change happening instantly. Whenever a property’s value changes — because of a :hover, a :focus, a class toggle from JavaScript, or any other state change — a transition tells the browser to animate between the old value and the new value over a period of time, using a chosen speed curve. It’s the simplest and most widely used form of CSS animation, and it’s the tool you reach for whenever you want an interface to feel smooth instead of abrupt.

Overview / How it works

A transition is an instruction to the browser’s rendering engine: “when this property changes, don’t just apply the new value immediately — interpolate between the old computed value and the new computed value over N seconds, following this easing curve.” For that instruction to do anything, three things must be true: (1) the element must have a starting value and an ending value for a property, (2) something must actually trigger the change (a pseudo-class like :hover, a class added by script, a media query match, and so on), and (3) the property must be one the browser knows how to interpolate. Numbers, lengths (px, %, rem), colors, and transform functions all interpolate cleanly. Keyword-only properties like display (none to block) or the special value auto generally cannot be smoothly interpolated, because the browser has no meaningful “halfway point” between two keywords or an unresolved automatic value.

Under the hood, the rendering engine treats transitionable properties differently depending on what they affect. Properties like transform and opacity can often be handled by the compositor alone — the browser can animate them on the GPU without recalculating page layout or repainting other content, which is why they’re the cheapest and smoothest properties to animate. Properties like width, height, top, or margin affect the box model directly, so animating them forces the browser to recompute layout (reflow) on every frame, which is more expensive and can cause visible jank on complex pages. Understanding this distinction is one of the most important things you can learn about transitions, and it shapes almost every best practice below.

It’s also worth knowing that a transition only runs when a property changes after the element already has a rendered state — it does not run automatically on the very first paint of the page. If you want an element to visibly transition in as soon as it appears, you typically need to add a class (or use the newer @starting-style at-rule, supported in current evergreen browsers) after the initial render, so the browser has an old value to transition away from.

Syntax

The general shape of a transition declaration is:

selector {
  transition: <property> <duration> <timing-function> <delay>;
}

The transition property is shorthand for four longhand properties, each of which can also be set individually:

Longhand Purpose Example values
transition-property Which property (or properties) to animate background-color, transform, all
transition-duration How long the animation takes 0.3s, 250ms
transition-timing-function The speed curve over the duration ease, linear, ease-in-out, cubic-bezier(0.4, 0, 0.2, 1)
transition-delay How long to wait before starting 0s, 0.1s

When you list several properties in the shorthand separated by commas, each comma-separated group can have its own duration, timing function, and delay, so different properties can animate at different speeds. If a property doesn’t get its own duration listed, the values are matched positionally and repeated round-robin across the property list.

Examples

Example 1: A simple hover transition

button {
  background-color: #3b82f6;
  color: #ffffff;
  padding: 10px 20px;
  border: none;
  border-radius: 6px;
  transition: background-color 0.3s ease;
}

button:hover {
  background-color: #1d4ed8;
}

Result: instead of the button’s background snapping instantly from light blue to dark blue when the pointer enters it, the color fades smoothly over 0.3 seconds, then fades back the same way when the pointer leaves.

This works because the transition declaration lives on the base button rule, not just the :hover rule — so it’s active in both directions, on the way in and on the way out.

Example 2: Multiple properties with different timing

.card {
  background-color: #ffffff;
  transform: scale(1);
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.25s ease-out, box-shadow 0.4s ease-in-out, background-color 0.2s linear 0.1s;
}

.card:hover {
  background-color: #f0f9ff;
  transform: scale(1.05);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
}

Result: on hover, the card grows to 105% size fairly quickly (0.25s), its shadow deepens more slowly (0.4s), and its background tint fades in over 0.2s but only starts after a 0.1s delay — so the three changes feel layered rather than perfectly synchronized.

This is the shorthand’s real power: three independent properties, each with its own duration, curve, and delay, declared in one line.

Example 3: A dropdown menu that fades and slides, with a visibility trick

.menu {
  opacity: 0;
  visibility: hidden;
  transform: translateY(-10px);
  transition: opacity 0.3s ease, transform 0.3s ease, visibility 0s linear 0.3s;
}

.menu.is-open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  transition: opacity 0.3s ease, transform 0.3s ease, visibility 0s linear 0s;
}

Result: when JavaScript adds the is-open class, the menu fades in and slides down 10px over 0.3s, becoming visible and interactive immediately. When the class is removed, it fades out and slides up, but stays visible (and therefore clickable and screen-reader reachable) for the full 0.3s fade before flipping to hidden at the very end thanks to the 0.3s delay on that closing transition.

Since visibility is a keyword property, it can’t fade — but giving it a zero-duration transition with a delay lets you time exactly when it flips, which is a common pattern for building accessible, non-janky show/hide animations.

How it works step by step

When a transitionable property changes on an element, the rendering engine roughly does the following on each animation frame until the duration elapses:

  • It records the computed starting value (the value right before the change) and the computed ending value (the new value being applied).
  • It calculates how far along the duration the current frame is, as a fraction from 0 to 1.
  • It passes that fraction through the timing function (for example ease-in-out or a custom cubic-bezier()) to get an adjusted progress value — this is what makes motion feel like it’s accelerating or decelerating rather than moving at a constant speed.
  • It interpolates the actual property value using that adjusted progress: numbers and lengths interpolate arithmetically, colors interpolate channel by channel, and transform functions are interpolated component by component (translation, scale, and rotation each blend independently).
  • If a transition-delay is set, the engine simply waits that long before starting the frame-by-frame process at all.
  • Once the duration has fully elapsed, the property settles at its exact final value, and (if scripted) a transitionend event fires that JavaScript can listen for.

Whether this work happens cheaply on the compositor thread (as with transform and opacity) or requires layout and paint on every frame (as with width, top, or margin) has a real, visible effect on smoothness, especially on lower-powered devices.

Common Mistakes

Mistake 1: Transitioning to/from height: auto

.accordion-panel {
  height: auto;
  transition: height 0.3s ease;
}

.accordion-panel.collapsed {
  height: 0;
}

This looks reasonable but doesn’t work the way people expect: the browser can animate to a fixed value like 0, but it cannot compute a meaningful in-between state for auto, so the expand transition either snaps instantly or doesn’t animate at all. The fix is to transition a property that always has a real, calculable number, most commonly max-height with a generous fixed cap, or to restructure the layout with CSS Grid’s grid-template-rows, which does support animating from 0fr to 1fr.

.accordion-panel {
  max-height: 500px;
  overflow: hidden;
  transition: max-height 0.3s ease;
}

.accordion-panel.collapsed {
  max-height: 0;
}

Mistake 2: Putting the transition only on the :hover rule

.box {
  background-color: blue;
}

.box:hover {
  background-color: red;
  transition: background-color 0.3s ease;
}

This is valid CSS, but it produces lopsided behavior: the color fades in smoothly to red while hovering, because the :hover rule (which carries the transition) is active. The moment the pointer leaves, the :hover rule — and its transition declaration — stops applying at the same instant the color reverts, so the color snaps back to blue instantly instead of fading. The fix is to put the transition declaration on the base selector so it stays active in both directions:

.box {
  background-color: blue;
  transition: background-color 0.3s ease;
}

.box:hover {
  background-color: red;
}

Best Practices

  • Prefer animating transform and opacity whenever possible — they’re compositor-friendly and stay smooth even on weaker devices.
  • Declare the transition property on the element’s base/resting rule, not only on its :hover, :focus, or state-toggle class, so the animation runs symmetrically in both directions.
  • Avoid transition: all in production code — it’s imprecise, can accidentally animate properties you didn’t intend to, and forces the browser to watch every property for changes.
  • Never rely on transitioning to or from auto; use max-height, CSS Grid’s fr unit tricks, or JavaScript-measured pixel values instead.
  • Choose timing functions intentionally: ease-out tends to feel natural for things entering the screen, ease-in for things leaving.
  • Keep UI feedback transitions short, generally in the 150–400ms range — longer durations start to feel sluggish rather than smooth.
  • Wrap non-essential motion in an @media (prefers-reduced-motion: reduce) query so users who’ve asked for reduced motion aren’t shown large or fast animations.

Practice Exercises

  • Style an image so that hovering over it scales it up to 110% over 0.2 seconds using transform: scale(), and think about why transform is preferable to changing width/height directly.
  • Build a card whose border-color and box-shadow both transition on hover, but make the box-shadow’s transition take exactly twice as long as the border-color’s transition, using one transition shorthand declaration.
  • Create a sliding sidebar panel that moves on/off screen using transform: translateX() instead of animating the left property, then explain in your own words why that choice avoids layout recalculation.

Summary

  • A CSS transition animates a property smoothly between its old and new computed value whenever that value changes due to a state change.
  • The transition shorthand sets transition-property, transition-duration, transition-timing-function, and transition-delay, and can list several comma-separated groups for different properties.
  • Only properties with interpolable values (numbers, lengths, colors, transforms) can transition; keyword swaps and auto generally cannot.
  • Put the transition declaration on the base rule so it applies symmetrically when a state is added and removed.
  • Favor transform and opacity for performance, since they can be animated without triggering layout recalculation.
  • Respect prefers-reduced-motion and keep interface transitions short and purposeful.