
CSS Transitions vs. Animations: Key Differences and Use Cases
You want a button's background to fade when someone hovers over it. Do you write a transition or an animation? Most developers pick one out of habit, and most of the time it works. But choosing the wrong tool leads to subtle problems: hover effects that snap back abruptly, entrance animations that can't be interrupted gracefully, or keyframes written for something a single line of transition could handle.
CSS transitions and CSS animations overlap, but they're built around different ideas. A transition describes how to move between two states when something changes. An animation describes a sequence that plays on its own terms. Once that distinction clicks, the choice becomes almost automatic.
In this post, I'll compare the two side by side, show where each one shines, and cover the newer features, like @starting-style and transition-behavior, that have shifted the balance in the last couple of years.
The One-Sentence Difference
- A transition interpolates a property from its old value to its new value when that value changes.
- An animation runs a predefined set of keyframes, starting whenever the animation is applied, regardless of any state change.
Transitions are reactive. Animations are scripted.
How Transitions Work
A transition needs three things: a property that can be interpolated, a duration, and a change in that property's value.
.button {
background-color: #4f46e5;
color: #fff;
transition:
background-color 200ms ease,
transform 200ms ease;
}
.button:hover {
background-color: #4338ca;
transform: translateY(-2px);
}
.button:active {
transform: translateY(0);
}
When the pointer enters the button, :hover changes background-color and transform. The browser sees the value change and animates between the old and new values over 200 milliseconds. When the pointer leaves, the value changes back, and the browser animates in reverse.
The transition longhands are:
transition-property: which properties to watch (allis allowed but usually a bad idea).transition-duration: how long it takes.transition-timing-function: the easing curve.transition-delay: how long to wait before starting.transition-behavior: whether discrete properties should participate (covered later).
Transitions Handle Interruption Gracefully
This is the most underrated feature of transitions. If the user hovers, then moves away halfway through, the transition reverses from its current position. Browsers even shorten the reverse duration proportionally, so a half-finished transition takes half as long to undo. You don't have to write anything to get this behaviour.
Animations don't work that way. If you swap a class that changes which animation is running, the new one starts from its own first keyframe, often causing a visible jump.
How Animations Work
An animation pairs an @keyframes rule with animation properties:
@keyframes spin {
to {
rotate: 1turn;
}
}
.spinner {
width: 2rem;
aspect-ratio: 1;
border: 3px solid #cbd5e1;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 800ms linear infinite;
}
The spinner starts rotating as soon as the element is rendered with that rule applied. No hover, no class change, no state is needed. It loops forever because of infinite.
Animations give you things transitions simply can't:
- Multiple intermediate steps, like
0%,30%,60%,100%. - Looping with
animation-iteration-count. - Alternating direction with
animation-direction: alternate. - Running without a state change, like on page load.
- Pausing and resuming with
animation-play-state. - Non-time timelines, such as scroll-driven animations via
animation-timeline.
Side-by-Side Comparison
| Transitions | Animations | |
|---|---|---|
| Trigger | A property value changes | The animation is applied |
| States | Two: start and end | As many keyframes as you want |
| Looping | No | Yes, including infinite |
| Runs on load | Only with @starting-style | Yes |
| Interruption | Reverses smoothly from current point | Restarts or jumps |
| Direction control | Implicitly reversible | reverse, alternate |
| Pause and resume | No direct control | animation-play-state |
| Code needed | One declaration | @keyframes plus properties |
| Scroll-linked | No | Yes, with animation-timeline |
When to Use Transitions
Reach for a transition whenever the motion connects two states that already exist in your CSS.
Hover, Focus, and Active States
.nav-link {
color: #475569;
text-decoration-color: transparent;
text-underline-offset: 0.3em;
transition:
color 150ms ease,
text-decoration-color 150ms ease;
}
.nav-link:hover,
.nav-link:focus-visible {
color: #0f172a;
text-decoration-color: currentColor;
}
Toggling UI Components
Accordions, drawers, and dropdowns that open and close based on a class or attribute are ideal. The reversibility means a user who clicks twice quickly gets smooth motion both ways.
.drawer {
position: fixed;
inset: 0 0 0 auto;
width: min(22rem, 90vw);
translate: 100% 0;
transition: translate 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawer[data-open="true"] {
translate: 0 0;
}
JavaScript only needs to flip data-open. CSS handles the motion.
Responding to Custom Property Changes
If a registered custom property changes, you can transition it. Here, a progress ring updates smoothly whenever the value is set from script:
@property --progress {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.ring {
width: 4rem;
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(#4f46e5 var(--progress), #e2e8f0 0);
transition: --progress 500ms ease-out;
}
document.querySelector(".ring").style.setProperty("--progress", "72%");
When to Use Animations
Reach for keyframes whenever the motion is not a simple A-to-B change, or it needs to happen without a state change.
Loading Indicators and Loops
Spinners, skeleton shimmer effects, and pulsing status dots loop continuously, so they need animations.
@keyframes shimmer {
to {
background-position: -200% 0;
}
}
.skeleton {
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
Multi-Step Sequences
An attention-grabbing "shake" on an invalid form field has several back-and-forth steps, which a transition can't express:
@keyframes shake {
20%,
60% {
translate: -6px 0;
}
40%,
80% {
translate: 6px 0;
}
}
.field[aria-invalid="true"] {
animation: shake 360ms ease-in-out;
}
Scroll-Linked Effects
Anything tied to scroll position requires an animation with animation-timeline, because transitions have no concept of a timeline other than their own duration.
The Gray Area: Entrance and Exit Effects
For a long time, entrance effects were firmly animation territory. A transition needs a "before" value to animate from, and an element that has just been inserted into the DOM, or has just switched from display: none, has no previous style. So transitions simply didn't run.
Two newer features change that.
@starting-style
@starting-style defines the styles an element should transition from the first time it's rendered, or when it goes from display: none to visible:
.toast {
opacity: 1;
translate: 0 0;
transition:
opacity 250ms ease,
translate 250ms ease;
@starting-style {
opacity: 0;
translate: 0 1rem;
}
}
When a .toast is added to the page, it now transitions in from transparent and slightly lowered. Before @starting-style, you'd have needed a keyframe animation or a JavaScript trick to force a reflow between two class changes.
transition-behavior: allow-discrete
display and overlay are discrete properties: they flip between values instead of interpolating. With transition-behavior: allow-discrete, you can include them in a transition, and the browser keeps the element displayed until the rest of the exit transition finishes.
Here's a popover that fades in and out, including its exit:
<button popovertarget="menu">Options</button>
<div id="menu" popover class="menu">
<a href="/account">Account</a>
<a href="/billing">Billing</a>
</div>
.menu {
opacity: 0;
scale: 0.96;
transition:
opacity 180ms ease,
scale 180ms ease,
display 180ms allow-discrete,
overlay 180ms allow-discrete;
}
.menu:popover-open {
opacity: 1;
scale: 1;
@starting-style {
opacity: 0;
scale: 0.96;
}
}
The closed state is the base style. When the popover opens, @starting-style gives the entrance a starting point. When it closes, allow-discrete delays the switch to display: none until the fade finishes. Including overlay keeps the popover in the top layer until the fade finishes.
@starting-style and transition-behavior are supported in current Chromium-based browsers, Safari, and Firefox, although Firefox's support for transitioning overlay has trailed the others. In an older browser, the popover simply appears and disappears instantly, which is a perfectly acceptable fallback.
So Which Should You Use for Entrances?
- If the element has a clear "open" and "closed" state that can be toggled back and forth, use a transition with
@starting-style. You get smooth reversal if the user closes it mid-entrance. - If the entrance is a one-off sequence with several steps, like a hero that bounces into place, use an animation.
Performance Is the Same
A common myth is that one of them is faster than the other. Performance is determined by what you animate, not by which mechanism you use. Transitions and animations on transform, opacity, and filter can both be composited. Transitions and animations on width, height, or top both trigger layout.
The one practical difference is that infinite animations keep running even when nobody's looking at them. Stop them when the element is hidden.
Working with JavaScript
Both mechanisms fire events you can listen to:
- Transitions fire
transitionrun,transitionstart,transitionend, andtransitioncancel. - Animations fire
animationstart,animationiteration,animationend, andanimationcancel.
A common pattern is to remove an element from the DOM after its exit transition:
function dismiss(toast) {
toast.classList.add("is-leaving");
toast.addEventListener("transitionend", () => toast.remove(), { once: true });
}
One gotcha: transitionend fires once per property. If you're transitioning opacity and translate, you'll get two events. Either check event.propertyName or use { once: true } as shown above.
Both CSS transitions and animations also show up in element.getAnimations(), which returns Web Animations API objects you can pause, reverse, or inspect.
A Quick Decision Guide
Ask these questions in order:
- Does it need to loop, or play more than two steps? Use an animation.
- Is it tied to scroll position? Use an animation with a scroll timeline.
- Does it respond to a state change, like hover, focus, a class, or an attribute? Use a transition.
- Is it an entrance for something that can also be dismissed? Use a transition with
@starting-style. - Is it a one-time entrance with no reverse? Either works. An animation is often simpler.
Common Pitfalls
- Using
transition: all. It transitions every property that changes, including ones you didn't intend, likecolorchanges from theme switches. List properties explicitly. - Putting the transition only on the hover state. If
transitionis declared only inside:hover, the effect animates in but snaps out. Declare it on the base state. - Trying to transition
height: auto. Classic transitions can't interpolate toauto. Newer browsers supportinterpolate-size: allow-keywordsand thecalc-size()function for this, but support is still limited, mainly to Chromium-based browsers, so check caniuse and keep a fallback such as transitioninggrid-template-rowsfrom0frto1fr. - Forgetting reduced motion. Both mechanisms should respect
prefers-reduced-motion. For transitions, shortening durations to near zero is usually enough.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
Conclusion
Transitions and animations aren't competitors. They answer different questions. A transition says "when this value changes, move smoothly to the new one", and it handles interruption and reversal for free. An animation says "play this sequence", with as many steps, loops, and directions as you need, and it can run on its own or be linked to scroll.
Use transitions for state changes: hover, focus, toggles, and, thanks to @starting-style and allow-discrete, entrances and exits of dismissible UI. Use animations for loops, multi-step choreography, and scroll-driven effects. Keep both on compositor-friendly properties, respect reduced motion, and you'll always have the right tool in hand.


