Skip to main content

CSS Transitions

CSS transitions are the simplest way to add motion to your interfaces. They animate the change between two states — from the current value of a property to a new value — whenever that property changes.

The Transition Property

A transition needs four pieces of information:

.button {
  transition: background-color 0.2s ease-out 0s;
  /*          property         duration timing  delay */
}
  • property — which CSS property to animate
  • duration — how long the transition takes
  • timing-function — the acceleration curve
  • delay — time before the transition starts (optional, defaults to 0)

You can transition multiple properties:

.card {
  transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
}

Or use all to transition every changing property (use with caution — it can animate properties you did not intend):

.card {
  transition: all 0.2s ease-out;
}

Hover States

The most common use of transitions is smooth hover effects:

.card {
  background: white;
  border-radius: 0.5rem;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  transform: translateY(0);
  transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
}

.card:hover {
  transform: translateY(-4px);
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}

Without the transition, the card would jump to its hover state instantly. With it, the card lifts smoothly upward and its shadow deepens, creating a sense of depth.

Focus States

Transitions also enhance keyboard navigation by making focus indicators feel polished:

.input {
  border: 2px solid #d1d5db;
  border-radius: 0.375rem;
  padding: 0.5rem 0.75rem;
  outline: none;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.input:focus-visible {
  border-color: #2563eb;
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}

The smooth transition from gray border to blue border with a glowing ring makes focus visible and pleasant.

Timing Functions Deep Dive

Timing functions control the speed curve of a transition. CSS provides built-in keywords and the cubic-bezier() function for custom curves:

/* Built-in keywords */
.ease-default { transition-timing-function: ease; }         /* Slow-fast-slow */
.ease-linear  { transition-timing-function: linear; }       /* Constant speed */
.ease-in      { transition-timing-function: ease-in; }      /* Slow start */
.ease-out     { transition-timing-function: ease-out; }     /* Slow end */
.ease-in-out  { transition-timing-function: ease-in-out; }  /* Slow start and end */

/* Custom cubic-bezier */
.snappy { transition-timing-function: cubic-bezier(0.2, 0, 0, 1); }

Each keyword maps to a specific cubic-bezier() curve:

Keywordcubic-bezierCharacter
ease(0.25, 0.1, 0.25, 1)General purpose
linear(0, 0, 1, 1)Mechanical, robotic
ease-in(0.42, 0, 1, 1)Accelerating — good for exits
ease-out(0, 0, 0.58, 1)Decelerating — good for entrances
ease-in-out(0.42, 0, 0.58, 1)Smooth — good for position changes

Use tools like cubic-bezier.com to experiment with custom curves visually.

Transition Performance

Not all properties are equal when it comes to performance. Stick to properties that can be hardware-accelerated:

/* Performant: GPU-accelerated */
.fast {
  transition: transform 0.2s ease, opacity 0.2s ease;
}

/* Slow: triggers layout recalculation */
.slow {
  transition: width 0.2s ease, height 0.2s ease, margin 0.2s ease;
}

If you need to animate size changes, use transform: scale() instead of width/height. If you need to animate position, use transform: translate() instead of top/left.

Common Patterns

Button Press Effect

.button {
  transition: transform 0.1s ease;
}

.button:active {
  transform: scale(0.97);
}

Fade-In on State Change

.tooltip {
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s ease, visibility 0.15s ease;
}

.trigger:hover .tooltip {
  opacity: 1;
  visibility: visible;
}

Using visibility alongside opacity ensures the hidden tooltip does not block clicks on elements behind it.

.link {
  color: #374151;
  text-decoration-color: transparent;
  transition: color 0.15s ease, text-decoration-color 0.15s ease;
}

.link:hover {
  color: #2563eb;
  text-decoration-color: #2563eb;
}

Expanding Border

.nav-link {
  position: relative;
  padding-bottom: 0.25rem;
}

.nav-link::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  width: 0;
  height: 2px;
  background: #2563eb;
  transition: width 0.2s ease-out;
}

.nav-link:hover::after {
  width: 100%;
}

Transition Gotchas

  • You cannot transition display — use opacity and visibility instead, or use the newer transition-behavior: allow-discrete (limited support).
  • You cannot transition auto valuesheight: auto cannot be transitioned. Use max-height with a large value as a workaround, or use CSS Grid's grid-template-rows: 0fr to 1fr trick.
  • Initial page load — transitions can fire on page load if an element starts in a different state. Add a no-transition class that you remove after load if this is a problem.
/* Grid row transition for expand/collapse */
.collapsible {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.3s ease;
}

.collapsible.open {
  grid-template-rows: 1fr;
}

.collapsible > div {
  overflow: hidden;
}

This is the cleanest way to animate an element from zero height to its natural content height.