Transitions and Animation
Motion in an interface has one job: to explain what just happened. A panel that slides out from the button you pressed tells you where it came from. A row that fades as it is removed tells you it is gone rather than hidden. Motion that does not explain anything is decoration, and decoration costs frames.
CSS gives you two tools. A transition animates a change you made. An animation runs a sequence you declared. Knowing which one a problem wants is most of the skill.
A transition animates a change
A transition does nothing on its own. It waits for a property to change, and then interpolates instead of jumping.
.button {
background: #0f766e;
transform: translateY(0);
transition: background 150ms ease, transform 150ms ease;
}
.button:hover { background: #115e59; }
.button:active { transform: translateY(1px); }
Four things make up a transition, and the shorthand takes them in this order: property, duration, timing function, delay. transition: transform 200ms ease-out 50ms.
Two rules about where to write it. Put the transition on the base state, not on :hover - otherwise the move in animates and the move out snaps back, because the rule carrying the transition no longer matches once the cursor leaves. And name the properties rather than writing transition: all. all means the browser watches every animatable property, which is both slower and a source of surprises when a later rule changes something you did not expect to move.
An animation runs a sequence
When the motion is not a change between two states - a spinner, a pulse, a three-step entrance - you declare the sequence as keyframes.
@keyframes pulse {
from { opacity: 1; }
50% { opacity: 0.4; }
to { opacity: 1; }
}
.saving {
animation: pulse 1.2s ease-in-out infinite;
}
animation takes name, duration, timing function, delay, iteration count, direction, fill mode and play state. In practice you write the first three or four and reach for the rest when you need them:
animation-fill-mode: forwardskeeps the final keyframe's values after the animation ends. Without it the element snaps back to its unanimated state, which is almost never what an entrance animation wants.animation-direction: alternateruns every other iteration backwards, so a two-keyframe animation becomes a smooth there-and-back without you writing the return trip.animation-delayaccepts negative values, which start the animation partway through. That is how you stagger a row of dots without three separate keyframe blocks.
A complete worked example
A notification that slides in, can be dismissed, and respects the user's motion preference.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Motion that explains</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
font: 16px/1.6 system-ui, sans-serif;
margin: 0;
padding: 2rem;
background: #fbfbfd;
color: #16181d;
}
@keyframes slide-in {
from { opacity: 0; transform: translateX(1.5rem); }
to { opacity: 1; transform: translateX(0); }
}
.toast {
display: flex;
align-items: center;
gap: 1rem;
max-width: 26rem;
padding: 1rem 1.25rem;
margin-block-end: 0.75rem;
background: #fff;
border: 1px solid #e3e5ea;
border-left: 3px solid #0f766e;
border-radius: 8px;
/* The entrance. forwards holds the end state. */
animation: slide-in 220ms cubic-bezier(0.2, 0, 0, 1) forwards;
}
.toast p { margin: 0; flex: 1; }
/* The exit is a state change, so it is a transition. The class
is added by script, the element is removed on transitionend. */
.toast.leaving {
opacity: 0;
transform: translateX(1.5rem);
transition: opacity 180ms ease-in, transform 180ms ease-in;
}
.dismiss {
border: 0;
background: transparent;
font: inherit;
color: #5c626e;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: background 120ms ease, color 120ms ease;
}
.dismiss:hover { background: #f1f2f5; color: #16181d; }
.dismiss:focus-visible { outline: 2px solid #0f766e; outline-offset: 2px; }
/* Not optional. Some people get motion sickness from this. */
@media (prefers-reduced-motion: reduce) {
.toast, .toast.leaving, .dismiss {
animation: none;
transition: none;
}
}
</style>
</head>
<body>
<h1>Toasts</h1>
<button id="add">Add a toast</button>
<div id="stack" role="status" aria-live="polite"></div>
<script>
const stack = document.getElementById('stack');
let n = 0;
document.getElementById('add').addEventListener('click', () => {
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = '<p>Saved change number ' + (++n) + '.</p>' +
'<button class="dismiss">Dismiss</button>';
stack.append(toast);
});
stack.addEventListener('click', (event) => {
if (!event.target.closest('.dismiss')) return;
const toast = event.target.closest('.toast');
toast.classList.add('leaving');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
});
</script>
</body>
</html>
Open this example in the editor and change it. Nothing you type is saved.
Notice the division of labour. The entrance is an animation, because there is no prior state to transition from - the element did not exist a moment ago. The exit is a transition, because it is a change from one state to another, and because transitionend gives us a reliable moment to remove the element from the document. Using an animation for the exit would mean listening for animationend and remembering forwards, which works but reads worse.
The two properties that are cheap, and why
A browser renders in stages: it computes styles, works out geometry (layout), paints pixels, and composites the painted layers onto the screen. Animating a property determines how far back in that pipeline it has to start again, every frame.
opacityandtransformare composited. The layer is already painted; the compositor just draws it somewhere else or with different alpha. This can happen off the main thread, which is why these keep animating smoothly even while JavaScript is busy.width,height,top,left,marginandpaddingforce layout. Changing one means recalculating the position of everything that depends on it, then repainting, then compositing - 60 times a second, on the main thread.
So transform: translateX(20px) and left: 20px look identical and cost nothing alike. Prefer the transform. For a position, animate transform: translate() rather than offsets; for a size, transform: scale() rather than width and height - accepting that scale stretches the content, which is fine for a backdrop and wrong for text.
The honest exception: height from 0 to auto has no transform equivalent. Modern CSS gives you interpolate-size: allow-keywords and calc-size() for this, and grid-template-rows from 0fr to 1fr works today in a grid wrapper. Both animate layout, so use them on one element at a time rather than on a list of thirty.
Timing functions say what kind of thing moved
The default ease is fine and slightly characterless. The choice that matters is direction:
ease-outstarts fast and settles. Right for anything entering - it arrives promptly and comes to rest.ease-instarts slow and accelerates away. Right for anything leaving.linearhas no acceleration. Right for a spinner or a progress bar, wrong for anything representing a physical object.cubic-bezier(0.2, 0, 0, 1)is a sharper ease-out: it moves decisively and lands softly. This is what most design systems mean by their standard easing.
Duration matters more than curve. Under 100ms reads as instant and the motion is wasted. Over about 400ms the interface feels slow and people start clicking again. Most interface motion belongs between 150ms and 250ms; a large element crossing a large distance can justify 300ms.
Reduced motion is a requirement
prefers-reduced-motion is set by people for whom animation causes nausea or vertigo - a vestibular disorder is not a preference about taste. Honour it on every page, every time.
The right response is usually not "no feedback at all" but "no movement": keep an opacity fade, drop the slide and the scale. A transition: none block, as in the example above, is the blunt version and is always acceptable. What is not acceptable is leaving it out.
When it goes wrong
| Symptom | Cause |
|---|---|
| It animates in but snaps out | The transition is on :hover, not on the base state |
| Nothing animates at all | The property is not animatable, or both ends are auto |
| An entrance animation reverts when it ends | animation-fill-mode: forwards is missing |
| Motion stutters while the page is busy | Animating layout properties instead of transform and opacity |
transitionend fires several times |
Several properties are transitioning; check event.propertyName |
transitionend never fires |
The value did not actually change, or the element was removed first |
A display: none element will not fade in |
It has no starting value to transition from; use @starting-style or a frame's delay |
| It is smooth on your laptop and awful on a phone | Too many animated elements, or a layout property on a long list |
A check you can run
Open DevTools, find the rendering panel, and turn on "Paint flashing" and the frame-rate meter. Then trigger your animation.
A transform or opacity animation should flash green once at the start and then stay quiet, holding a steady frame rate. A width or top animation repaints a region every single frame - you will see it flashing continuously, and on a mid-range phone you will see the frame rate fall with it. That difference, visible in a few seconds, is the whole argument for the two cheap properties.
Then set your operating system to reduce motion and reload. If anything still slides, you have a bug that affects real people.