← Back to Index

Lesson 6

CSS Transitions

A transition is the simplest form of CSS animation: you define a start state and an end state, and the browser interpolates between them over time. No keyframes, no timeline - just "when this property changes, do it smoothly."

The four parts of a transition

transition: <property> <duration> <timing-function> <delay>;

/* Example */
transition: background 0.3s ease 0s;

/* Shorthand with multiple properties */
transition:
  background 0.3s ease,
  transform 0.2s ease-out,
  box-shadow 0.4s ease;
  1. transition-property - which property to animate (all, a specific name, or a comma-separated list)
  2. transition-duration - how long the interpolation takes (e.g. 0.3s, 300ms)
  3. transition-timing-function - the acceleration curve
  4. transition-delay - how long to wait before starting (default 0s)

Where to put it: The transition declaration goes on the base state, not the hover/active state. This way it applies in both directions - entering AND leaving the state change.

Why? The transition declaration is only in effect while its selector matches. If you put it on :hover, then when the mouse leaves, the :hover block deactivates - taking the transition instruction with it. The property snaps back instantly because there's no transition in effect for the "return trip."

Asymmetric transitions - different timing for enter vs leave

You can exploit this behaviour intentionally. By putting different transition values on the base and hover states, you get different speeds for each direction:

.button {
  background: blue;
  transition: background 0.6s ease;  /* governs the LEAVE (slow fade back) */
}

.button:hover {
  background: coral;
  transition: background 0.1s ease;  /* governs the ENTER (fast snap on) */
}

When you hover, the :hover transition kicks in (fast 0.1s). When you leave, the :hover rules disappear, and the base state's transition takes over (slow 0.6s). This gives you a fast snap-in, slow fade-out effect - useful for things like button highlights that should feel responsive on hover but gentle on leave.

Live demo: basic transition

Hover to transition width and background simultaneously

Hover me
<!-- HTML -->
<div class="basic-box">Hover me</div>

/* CSS */
.basic-box {
  width: 100px;
  height: 60px;
  background: var(--accent);
  border-radius: 6px;
  transition: width 0.4s ease, background 0.4s ease;
}

.basic-box:hover {
  width: 300px;
  background: #e76f51;
}

Timing functions - the acceleration curve

The timing function controls how the value changes over time. It doesn't change the start/end - just how you get there.

Hover anywhere in this box - all bars transition to full width with different timing

ease
linear
ease-in
ease-out
ease-in-out
cubic-bezier
<!-- HTML -->
<div class="timing-row">
  <div class="timing-bar">ease</div>
  <div class="timing-bar">linear</div>
  <div class="timing-bar">ease-in</div>
  <div class="timing-bar">ease-out</div>
  <div class="timing-bar">ease-in-out</div>
  <div class="timing-bar">cubic-bezier</div>
</div>

/* CSS */
.timing-bar:nth-child(1) { transition: width 1s ease; }
.timing-bar:nth-child(2) { transition: width 1s linear; }
.timing-bar:nth-child(3) { transition: width 1s ease-in; }
.timing-bar:nth-child(4) { transition: width 1s ease-out; }
.timing-bar:nth-child(5) { transition: width 1s ease-in-out; }
.timing-bar:nth-child(6) { transition: width 1s cubic-bezier(0.68, -0.55, 0.27, 1.55); }

.timing-row:hover .timing-bar { width: 100%; }

The built-in keywords:

cubic-bezier() - custom curves

All timing keywords are shortcuts for cubic-bezier() curves. You can write your own:

/* Bouncy overshoot */
transition-timing-function: cubic-bezier(0.68, -0.55, 0.27, 1.55);

/* The keywords as cubic-bezier: */
ease:        cubic-bezier(0.25, 0.1, 0.25, 1.0)
linear:      cubic-bezier(0.0, 0.0, 1.0, 1.0)
ease-in:     cubic-bezier(0.42, 0, 1.0, 1.0)
ease-out:    cubic-bezier(0, 0, 0.58, 1.0)
ease-in-out: cubic-bezier(0.42, 0, 0.58, 1.0)

Practical tip: Use cubic-bezier.com to visually design curves. Values outside 0 - 1 on the Y axis create overshoot/bounce effects (the bar in the demo above overshoots then snaps back).

Transition delay - staggered effects

A delay makes the transition wait before starting. Combined with different delays per element, you get stagger effects:

Hover the row - each box starts its transition slightly later

<!-- HTML -->
<div class="delay-row">
  <div class="delay-box"></div>
  <div class="delay-box"></div>
  <div class="delay-box"></div>
  <div class="delay-box"></div>
  <div class="delay-box"></div>
</div>

/* CSS */
.delay-box {
  transition: transform 0.3s ease, background 0.3s ease;
}

.delay-box:nth-child(1) { transition-delay: 0s; }
.delay-box:nth-child(2) { transition-delay: 0.1s; }
.delay-box:nth-child(3) { transition-delay: 0.2s; }
.delay-box:nth-child(4) { transition-delay: 0.3s; }
.delay-box:nth-child(5) { transition-delay: 0.4s; }

.delay-row:hover .delay-box {
  transform: translateY(-10px);
  background: teal;
}
Ad

Multiple properties - independent timing

You can give each property its own duration, timing, and delay:

Hover - each property transitions at its own speed

Hover
<!-- HTML -->
<div class="multi-box">Hover</div>

/* CSS */
.multi-box {
  width: 100px;
  height: 60px;
  background: var(--accent);
  border-radius: 6px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  transition:
    background 0.3s ease,
    transform 0.2s ease,
    box-shadow 0.3s ease,
    border-radius 0.4s ease;
}

.multi-box:hover {
  background: #e76f51;
  transform: scale(1.1);
  box-shadow: 0 8px 24px rgba(0,0,0,0.2);
  border-radius: 50%;
}

transition: all - convenient but risky

/* Transitions every animatable property */
transition: all 0.3s ease;

This is tempting but has downsides:

Best practice: Be explicit. List the properties you want to transition. Use all only in prototyping or when you genuinely want everything to animate.

What can and can't be transitioned

A property can be transitioned if the browser knows how to interpolate between its values. Most numeric/colour properties work:

New: transition-behavior: allow-discrete

A recent addition (Chrome 117+) lets you transition properties that would normally swap discretely. The most useful case: transitioning display so elements can fade out before being removed:

.box {
  transition: opacity 0.4s ease, display 0.4s ease allow-discrete;
}

.box.hidden {
  opacity: 0;
  display: none;  /* transitions at the END of the opacity fade */
}

Without allow-discrete, adding display: none would instantly hide the element before the opacity had a chance to fade. With it, the display change waits until the transition finishes.

Transitioning custom properties (callback to Lesson 2)

Remember: unregistered custom properties can't be transitioned. But with @property, you can transition anything that has a numeric component inside it. Here's a gradient angle rotating on hover:

Hover - the gradient angle transitions from 0deg to 180deg

Hover me
@property --gradient-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

<!-- HTML -->
<div class="gradient-angle-box">Hover me</div>

/* CSS */
.gradient-angle-box {
  background: linear-gradient(var(--gradient-angle), blue, coral);
  transition: --gradient-angle 0.8s ease-in-out;
}

.gradient-angle-box:hover {
  --gradient-angle: 180deg;
}

The pattern: Extract → Register → Transition. Pull a numeric part out of a compound value into a custom property, register it with @property, then transition the property. Works for gradient stops, hues, angles, individual transform values - anything.

Performance: what's cheap to transition

Not all transitions are created equal. The browser renders in layers, and some property changes are much cheaper than others:

Cost Properties Why
Cheap (composite) transform, opacity GPU-accelerated. No layout or paint needed.
Medium (paint) color, background, box-shadow, border Triggers repaint but no layout recalculation.
Expensive (layout) width, height, padding, margin, top/left Triggers layout recalculation for the element AND its neighbours.

Rule of thumb: If you can achieve the same visual effect with transform (e.g. scale() instead of changing width, translateY() instead of changing top), prefer transform. It's composited on the GPU and won't trigger layout thrash.

Common gotchas

1. Transitioning height to/from auto

You can't transition height: 0height: auto because the browser can't interpolate to an unknown value. Workarounds:

2. Transition on page load

Transitions don't fire on initial page load - they only trigger when a property changes. If you want an entrance animation, use @keyframes (Lesson 7).

3. Transitioning to display: none

Covered above - use transition-behavior: allow-discrete or the classic workaround: transition opacity to 0 first, then toggle display in a transitionend event handler.

Retrieval check

Question 1

Where should the transition declaration go - on the base state or the hover/active state?

Base state - so it applies in both directions
Hover state - that's where the change happens
Either - it doesn't matter where you put it
A separate @transition rule at the top of the file

Question 2

Which two properties are cheapest to transition (GPU composited)?

width and height
background and color
transform and opacity
padding and margin

Question 3

You want an element to start moving slowly and accelerate. Which timing function?

ease-out - decelerates at the end
ease-in - starts slow, gets faster
ease - slow at start and end equally
linear - constant speed throughout

Question 4

Can you transition height from 0 to auto?

Yes - auto is a valid target for any transition
No - browser can't interpolate to unknown value (use workarounds)
Only if you set transition-behavior: allow-discrete
Only with a registered @property for height

Question 5

What does transition-behavior: allow-discrete enable?

Allows transitions on unregistered custom properties
Makes all transitions run on the GPU
Lets discrete properties like display participate in transitions
Enables transitions to fire on page load