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:
| Keyword | cubic-bezier | Character |
|---|---|---|
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.
Color Shift Link
.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— useopacityandvisibilityinstead, or use the newertransition-behavior: allow-discrete(limited support). - You cannot transition
autovalues —height: autocannot be transitioned. Usemax-heightwith a large value as a workaround, or use CSS Grid'sgrid-template-rows: 0frto1frtrick. - Initial page load — transitions can fire on page load if an element starts in a different state. Add a
no-transitionclass 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.