Keyframe
Keyframe · The log

CSS Transition vs Animation: Which One and When

July 18, 2026 · Uncategorized

Two tools, one job description, and a name collision that keeps everyone slightly confused. CSS has transitions and CSS has animations, both move a property from one value to another over time, and the words get used interchangeably in conversation even though the code is different. Pick the wrong one and you fight the language for an hour. Pick the right one and it is three lines.

Here is how to always know which is which.

The one-line difference: trigger vs auto

A transition needs a push. An animation goes on its own.

That is the whole thing. A CSS transition animates a change between two states, and it requires a triggering state change to fire. Hover, focus, a toggled class, an attribute flip. Something has to move the property from value A to value B, and the transition smooths the gap. Take away the trigger and nothing happens, because a transition has no idea what to do until a value actually changes.

A CSS animation is the opposite temperament. It runs automatically on load, marches through the stops you defined in @keyframes, and needs no interaction at all. It can also loop without anyone touching the page, which a transition flatly cannot do.

So the decision tree is short. Is there a clear before and after, kicked off by the user or a state change? Transition. Does it need to run on its own, repeat, or hit more than two positions? Animation. Everything else is detail.

When a transition is the right call

Reach for a transition when you already have two states and you just want the trip between them to be smooth. Hover states are the textbook case. The :hover state commonly triggers a transition, and it is the reason buttons feel alive instead of snapping.

.btn {
  transform: scale(1);
  transition: transform 150ms ease-out;
}

.btn:hover {
  transform: scale(1.05);
}

Read that top to bottom. The base rule holds the resting value. The :hover rule holds the target. The transition line, sitting on the base rule, describes how to travel between them. When the pointer leaves, the property returns to its base value and the same transition carries it back. Free reverse, no extra code.

The transition shorthand packs four longhands into one line, in this order: property, duration, timing function, delay.

This is exactly the toolkit behind most interactive UI. Hover growth, focus rings, color fades, a panel sliding open. If you build interface controls, the button animation CSS patterns lean almost entirely on transitions for this reason.

When you need @keyframes

Some motion has no trigger and no natural “other state.” A spinner spins because it is loading, not because you hovered it. A pulse pulses forever. A ball bounces through squash, stretch, and settle, which is four or five positions, not two. That is animation territory.

.spinner {
  animation: spin 800ms linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

No hover, no class toggle, no user in the loop. The animation starts when the element renders and repeats because infinite told it to. A transition simply cannot express this. It has no keyframes to loop and no way to start itself.

The other tell is intermediate positions. A transition only knows start and end. If your motion passes through waypoints, an entrance that drops in, overshoots, and settles, you need named stops, and stops live in @keyframes. When your keyframes get involved, the complete guide to keyframe rules covers the from/to versus percentage question and what happens to properties you leave out of a stop.

The two default-0s traps

Both tools share a quiet failure mode, and it is the same value in both: zero seconds. Nothing errors. Nothing warns. The motion just does not happen, and you sit there refreshing.

Trap one, the transition with no duration. transition-duration defaults to 0s. Write transition: transform ease-out and forget the time, and every hover snaps instantly, because a zero-length transition is an instant one. It looks like the transition “isn’t working.” It is working perfectly, just in no time at all.

Trap two, the animation with no duration. animation-duration also defaults to 0s, and a 0s animation produces no visible animation. Your @keyframes are flawless. Your animation-name is spelled right. But with no duration the browser runs all those keyframes in zero time and you see the final state with no journey to it.

The fix for both is the same discipline: always write the duration first and check it is not zero. When a transition or animation “does nothing” and throws no error, this is the first place to look, before you doubt the keyframes or the selector.

A related transition gotcha: the property needs a real starting value in the base rule. If the base rule never sets, say, opacity, there is no A to leave from and the browser has nothing to interpolate. A transition needs both ends written down.

Transitioning to and from display:none

For years this was the wall everyone hit. You fade a menu in with opacity, fine. You try to fade it out and remove it from layout with display: none, and it vanishes instantly with no fade, because display is a discrete property. Discrete properties flip from one value to the other with no in-between, so they do not animate under a normal transition. People worked around it with timeouts, visibility hacks, and JavaScript that fought the browser.

That wall is gone. Two features knock it down.

transition-behavior: allow-discrete opts a discrete property like display into transitions, so the browser holds display: block until the rest of the transition finishes, then flips to none. And @starting-style supplies the value to animate from when an element first appears, which is what you need for an entry, since the element has no previous state to leave from.

.menu {
  opacity: 1;
  transition: opacity 200ms ease, display 200ms allow-discrete;
}

/* leaving: display waits for the fade, then flips to none */
.menu[hidden] {
  opacity: 0;
  display: none;
}

/* entering: the value to animate FROM on first paint */
@starting-style {
  .menu {
    opacity: 0;
  }
}

Read the transition line. Opacity gets its own duration and easing. Display rides along with allow-discrete, which is what keeps the element in the box model long enough for the fade to play before it disappears. The @starting-style block is the entry seed: on the very first frame the menu is transparent, then it transitions to opaque.

Support, because you will ask. Both landed as Baseline in August 2024, meaning Chrome 121 and up, Safari 17.5 and up, Firefox 129 and up. Firefox was the last engine in, and its early builds were rough on entering from display: none, so test that exit-and-return case in Firefox specifically before you trust it. On older browsers the element still appears and disappears correctly, it just skips the fade, which is a fine fallback.

Why does this belong in a transition-versus-animation piece? Because it settles an old argument. People used to write full keyframe animations purely to dismiss a dropdown, because transitions “couldn’t do display.” Now a transition can, and for a two-state show-and-hide a transition is the simpler, more maintainable choice. The mental model holds: two states with a trigger stays a transition.

A cheat sheet to keep

Both tools live under the same roof, the CSS animation model of moving a property over time with a timing curve. Transitions are the interactive half, animations are the autonomous half. Learn which question you are answering and the syntax stops fighting you.

Related

← Back to the log