Animations & Transitions
transition vs @keyframes animation, easing functions, and a complete animated toast notification component.
transition: animating between two states
A transition smoothly interpolates a property from its old value to its new value whenever that value changes — triggered by a :hover, a class toggle, or a JavaScript-driven style change. It has no idea of any state in between; it only needs a start and an end:
.button {
background: #3b82f6;
transition: background 0.2s ease;
}
.button:hover {
background: #2563eb;
}
The transition shorthand packs four pieces of information: property duration timing-function delay.
.card {
transition: transform 0.3s ease-out, box-shadow 0.3s ease-out;
/* ^property ^duration ^easing (delay omitted, defaults to 0s) */
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
}
Use all to transition every animatable property that changes, rather than listing each one — convenient, but it also transitions properties you didn't intend to animate if something else on the element changes unexpectedly:
.button {
transition: all 0.2s ease;
}
Easing functions
The timing function shapes how a transition or animation moves between its two values over time — not just linearly, but accelerating, decelerating, or both:
| Function | Feel |
|---|---|
linear |
Constant speed throughout — often feels mechanical/unnatural for UI motion. |
ease (default) |
Starts slow, speeds up, ends slow — the most commonly used general-purpose default. |
ease-in |
Starts slow, accelerates, ends abruptly — good for something leaving the screen. |
ease-out |
Starts fast, decelerates smoothly — good for something entering the screen or settling into place. |
ease-in-out |
Slow at both ends, fast in the middle. |
cubic-bezier(x1, y1, x2, y2) |
A fully custom curve — lets you define a bespoke easing feel beyond the five keyword presets. |
.toast {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); /* a slight overshoot "bounce" */
}
@keyframes and the animation property
A keyframe animation defines any number of intermediate steps, not just a start and end, and can run automatically, repeat, and reverse — none of which a transition can do on its own.
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes can also use percentages for more than two steps:
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
.notification-dot {
animation: pulse 1.5s ease-in-out infinite;
}
The animation shorthand packs several sub-properties, most usefully: animation-name, animation-duration, animation-timing-function, animation-iteration-count (a number, or infinite), animation-direction (normal, reverse, alternate), and animation-fill-mode (whether the element keeps the first/last keyframe's styles before it starts or after it ends).
.slide-in {
animation: slideIn 0.4s ease-out forwards; /* "forwards" keeps the final keyframe's state after it finishes */
}
@keyframes slideIn {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
transition vs. animation
transition |
animation (@keyframes) |
|
|---|---|---|
| Needs a trigger | Yes — a state change (:hover, a class toggle, a JS style change) |
No — can run automatically on load |
| Number of steps | Two (from → to) | Any number, via percentage keyframes |
| Looping | No | Yes (animation-iteration-count: infinite) |
| Reversing mid-flight | Naturally, by interpolating back if the state reverts | Only with explicit animation-direction control |
| Typical use | Hover/focus feedback, simple state changes | Loaders, attention-grabbers, multi-step entrances |
A complete example: an animated toast notification
Combining a transition-driven slide-in/out toast with a @keyframes-driven loading spinner shown inside it:
<button id="show-toast">Save changes</button>
<div id="toast" class="toast" role="status" aria-live="polite">
<span class="spinner" hidden></span>
<span class="toast-message"></span>
</div>
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
background: #111827;
color: white;
padding: 0.75rem 1.25rem;
border-radius: 8px;
opacity: 0;
transform: translateY(1rem);
transition: opacity 0.3s ease, transform 0.3s ease;
pointer-events: none;
}
.toast.visible {
opacity: 1;
transform: translateY(0);
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
const button = document.getElementById('show-toast');
const toast = document.getElementById('toast');
const spinner = toast.querySelector('.spinner');
const message = toast.querySelector('.toast-message');
let hideTimeout;
button.addEventListener('click', () => {
spinner.hidden = false;
message.textContent = 'Saving...';
toast.classList.add('visible');
// Simulate a save completing
setTimeout(() => {
spinner.hidden = true;
message.textContent = 'Saved successfully!';
}, 900);
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => {
toast.classList.remove('visible');
}, 3000);
});
The toast's entrance/exit uses transition because it only ever needs two states (hidden vs. visible), each fully described by a single opacity/transform pair. The spinner inside it uses @keyframes because it needs to loop continuously with no natural "end state" to transition to — exactly the case each tool is actually suited for.
Common mistakes
- Trying to
transitiona property change that never actually happens on a single element (likedisplay: none→display: block) —displayisn't animatable at all, and a component that's removed from layout can't meaningfully transition its other properties either. Fade withopacity/visibilityinstead, or restructure the toggle to avoiddisplayentirely. - Using
transition: allbroadly and then being surprised when an unrelated property change (a layout shift, a different state class) animates when it shouldn't have. - Forgetting
animation-fill-mode: forwardson an entrance animation, causing the element to snap back to its first keyframe's state the instant the animation completes, undoing the visual effect it just played. - Animating
width/height/top/leftfor movement instead oftransform: translate()/scale()— transform-based animation can run on the compositor thread and stays smooth even under load, while animating layout properties forces the browser to recalculate layout on every frame.