Quick Answer

Use a transition when a state change triggers the movement, such as hover, focus or a class added by JavaScript, and the element only needs to move between two values. Use a keyframe animation when the movement must run on its own without a trigger, repeat, or pass through more than two steps. Transitions need two real computed values, which is why display:none and height:auto silently break them. For smoothness, animate transform and opacity rather than width, height or top.

The rule for choosing between them

Both features interpolate a property over time. The difference is what starts them.

A transition is reactive. Nothing happens until a computed value changes: the user hovers, an input gets focus, a media query flips, or JavaScript adds a class. The browser notices the old and new values and fills in the middle. If nothing changes, nothing moves.

An animation is self-starting. The moment the rule applies, the keyframes run, with no state change required at all. It can loop forever, run backwards, pause, and pass through as many intermediate steps as you define.

So the choice comes down to three questions.

  • Is there a trigger? No trigger means you need an animation. A spinner, a skeleton shimmer or an attention pulse has nothing to react to.
  • How many steps? Two values means a transition. Anything with a middle point, such as a bounce that overshoots and settles, needs keyframes.
  • Does it repeat? Transitions run once per change. Repetition is animation-iteration-count.

In practice most interface polish is transitions. Buttons, links, cards lifting on hover, inputs highlighting on focus, panels sliding open when a class is toggled. Animations show up for loading states, entrance effects that must fire on page load, and anything decorative.

One case is genuinely ambiguous: an element that appears once and animates in. A transition cannot do it cleanly because the element has no previous state to move from, so the usual answer is a short keyframe animation with animation-fill-mode: both. That is exactly the gap the next section is about.

Both are declarative, both run outside your JavaScript, and both are cheaper than moving elements with a setInterval loop. Neither should be your first tool for layout that changes on a real state change in data. Animate the presentation, not the structure.

Transitions need two real values

Start with the placement mistake almost everyone makes once. Put transition on the base rule, not on the state.

/* wrong: fades in, snaps out */
.btn:hover { background: #0847c4; transition: background 200ms ease; }

/* right: symmetric */
.btn { background: #0b5fff; transition: background 200ms ease; }
.btn:hover { background: #0847c4; }

When the transition lives only in :hover, it stops applying the instant the pointer leaves, so the return trip is instant. Occasionally that asymmetry is what you want, and then it is a deliberate trick rather than a bug.

The bigger rule is that a transition needs a real starting value and a real ending value. Two things violate that constantly.

display: none. This is the number one reason a fade does nothing.

/* nothing fades */
.menu       { display: none; opacity: 0; transition: opacity 200ms ease; }
.menu.open  { display: block; opacity: 1; }

The element is not rendered at all while hidden, so there is no starting frame to interpolate from. It appears fully opaque, immediately. The long-standing fix uses visibility, which is transitionable, and delays hiding until the fade finishes:

.menu {
  opacity: 0;
  visibility: hidden;
  transition: opacity 200ms ease, visibility 0s linear 200ms;
}
.menu.open {
  opacity: 1;
  visibility: visible;
  transition: opacity 200ms ease, visibility 0s;
}

Newer CSS adds transition-behavior: allow-discrete together with @starting-style to transition display directly. Support is not universal yet, so treat it as a progressive enhancement rather than the default.

height: auto. auto is a keyword, not a length, so there is nothing to interpolate. The old workaround animates max-height from 0 to a value larger than the content, which works but makes the duration a lie, because the visible part of the movement finishes early. A cleaner modern option is a grid row from 0fr to 1fr with the content wrapped in a child that has overflow: hidden.

Keyframes and the fill-mode gotcha

A keyframe animation is defined once and referenced by name.

@keyframes fade-up {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: none; }
}

.toast {
  animation: fade-up 300ms ease-out both;
}

The shorthand order that matters in practice is name, duration, timing function, delay, iteration count, direction and fill mode. Duration comes first among the two time values; if you write two times, the first is duration and the second is delay.

That trailing both is animation-fill-mode, and leaving it out is the classic keyframe bug. By default an animation only paints during its run. Before it starts and after it ends, the element goes back to its normal styles. So a fade-in with a delay is fully visible during the delay, then jumps to invisible and fades in again, and an animation that ends at opacity: 1 snaps back to whatever the rule says the moment it finishes. forwards keeps the final frame, backwards applies the first frame during the delay, and both does both.

Percentages let you add the middle steps a transition cannot express:

@keyframes attention {
  0%, 100% { transform: scale(1); }
  40%      { transform: scale(1.06); }
  70%      { transform: scale(0.98); }
}

The other thing worth knowing is how to replay an animation. Removing and re-adding the class in the same tick does nothing, because the browser batches the change and sees no difference. Force a reflow in between:

const el = document.querySelector(".box");

el.classList.remove("shake");
void el.offsetWidth;   // read a layout property to force a reflow
el.classList.add("shake");

Cleaner still is listening for animationend and removing the class there, so the element is always ready for the next run without the reflow hack.

Why transform and opacity are smooth and width is not

To paint a frame the browser does roughly four things in order: recalculate styles, run layout to work out the geometry of every box, paint the pixels for each layer, then composite the layers together on screen.

Which of those steps a property touches decides how expensive it is per frame.

  • Geometric properties such as width, height, top, left, margin and padding change the box model, so layout runs again. Changing one element's width can push its siblings and reflow its ancestors, so the work is not proportional to the element you touched.
  • Paint properties such as background-color, box-shadow and border-radius skip layout but force pixels to be redrawn. A large blurred shadow redrawn sixty times a second is one of the more expensive things you can casually write.
  • transform and opacity can usually be handled at the composite step alone. The element is already painted; the compositor just draws the same texture at a different offset, scale or alpha. That is why they stay smooth while the rest of the page is busy.
/* forces layout on every frame */
.card { transition: width 300ms ease; }
.card:hover { width: 320px; }

/* composited */
.card { transition: transform 300ms ease; }
.card:hover { transform: scaleX(1.25); }

The same trade explains the standard substitutions: use translate instead of top and left, scale instead of width and height, and fade a pseudo-element that carries a pre-painted shadow instead of transitioning box-shadow itself.

will-change: transform tells the browser to prepare a separate layer in advance, which can remove a stutter on the first frame. Use it sparingly and remove it when the animation is over. Every promoted layer costs memory, and applying it to everything makes things slower, not faster.

One caveat: scale stretches already-painted pixels, so text scaled up can look soft mid-animation. For a card that must resize crisply, animating the geometry and accepting the cost is sometimes the honest answer.

Timing functions and motion that respects the user

Duration decides how long. The timing function decides how the value is distributed across that time, and it does more for how an interaction feels than the duration does.

The default for both transitions and animations is ease, which starts quickly and slows at the end. The named keywords are shortcuts for cubic Bézier curves, and you can write your own:

.btn      { transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); }
.enter    { animation: fade-up 240ms cubic-bezier(0, 0, 0.2, 1) both; }
.exit     { animation: fade-out 180ms cubic-bezier(0.4, 0, 1, 1) both; }

The rough guidance most design systems settle on: elements entering the screen should decelerate, so use a curve that starts fast and eases out. Elements leaving should accelerate away, so ease in. Movement that stays on screen, like a toggle, uses a curve that eases both ends. linear looks mechanical for anything spatial, but it is correct for a spinner or a progress bar, where constant speed is the honest reading.

steps() jumps instead of sliding, which is how sprite-sheet animation and typewriter effects are built:

.sprite { animation: run 600ms steps(8, end) infinite; }

A detail people miss: on an animation, the timing function applies to each segment between keyframes, not to the whole run. Five keyframes give you four segments, so ease means four separate accelerate-and-slow curves in a row, which reads as a stutter. Set animation-timing-function: linear and shape the motion with keyframe positions instead, or put a timing function inside individual keyframes.

Finally, honour the operating system setting for reduced motion. Some people get genuinely unwell from large sliding and parallax movement, and on lower-end phones heavy animation just makes the interface feel slow.

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Near-zero rather than zero keeps animationend and transitionend handlers firing, so any JavaScript that waits for them still works.

Frequently Asked Questions

Why does my fade-in do nothing when the element starts at display: none? A transition interpolates between two computed values, and an element with display: none is not rendered, so there is no first frame to start from. When you switch to display: block and opacity: 1 in the same change, the element simply appears fully opaque. Use visibility with a delayed transition, or animate it in with keyframes, or use transition-behavior: allow-discrete where support allows.
Can I transition height from 0 to auto? Not directly, because auto is a keyword with no numeric value to interpolate. The common workaround animates max-height from 0 to a number comfortably larger than the content, at the cost of the timing being inaccurate. A tidier modern approach animates a grid row from 0fr to 1fr with the content in a child that has overflow: hidden, and JavaScript measuring the real height is still the most exact option.
Is transform actually faster than changing top and left? Usually, and for a structural reason rather than a micro-optimisation. Changing top or left alters the box geometry, so the browser runs layout again for that element and potentially its ancestors and siblings on every frame. A transform changes how an already-painted layer is drawn, which the compositor can often do without redoing layout or paint at all.
How do I run an animation again after it has finished? Removing and immediately re-adding the class does nothing, because the browser batches style changes and never sees a difference. Force a reflow between the two by reading a layout property such as element.offsetWidth. A cleaner option is to listen for the animationend event and remove the class there, so the element is already reset the next time you need it.
Should I use CSS animations or a JavaScript animation library? Start with CSS. It is declarative, it runs without your code being involved, and composited properties keep moving even when the main thread is busy. Reach for the Web Animations API or a library when you need to sequence many steps, animate to a value you can only measure at runtime, or pause, reverse and inspect the animation from code.