Concept
@keyframes animations define a self-contained motion sequence, multiple named stages (not just a start and end like transitions), the ability to run automatically without an external trigger, and looping. This is the right tool whenever motion needs more than two states, needs to start on its own (a loading spinner), or needs to repeat.
Defining and using keyframes
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}/* Multiple stages via percentages */
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}from/to is shorthand for 0%/100%, used when there are only two stages; percentage syntax is required the moment you need intermediate stages.
The animation shorthand and its individual properties
.element {
animation-name: pulse;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-delay: 0s;
animation-iteration-count: infinite; /* or a number, e.g. 3 */
animation-direction: normal; /* normal | reverse | alternate | alternate-reverse */
animation-fill-mode: none; /* none | forwards | backwards | both */
animation-play-state: running; /* running | paused, can be toggled via JS/hover for pause-on-hover effects */
/* shorthand order: name duration timing-function delay iteration-count direction fill-mode */
animation: pulse 2s ease-in-out 0s infinite alternate both;
}animation-fill-mode is the property most often forgotten and causes real bugs: without it, an element snaps back to its pre-animation styles the instant the animation completes (or before it starts, for a delayed animation), even though the keyframes clearly defined a different end state. forwards keeps the animation's final keyframe styles applied after it ends; backwards applies the first keyframe's styles during any animation-delay (before it starts); both does both.
/* Without fill-mode: forwards, the element SNAPS BACK to opacity:0 the instant the animation ends */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.item {
animation: fadeIn 0.5s ease-out;
animation-fill-mode: forwards; /* keeps opacity: 1 after the animation completes */
}animation-direction, alternating without duplicating keyframes
.pulse { animation: pulse 1s ease-in-out infinite alternate; }alternate runs the animation forward, then backward, then forward again on each iteration, useful for a smooth back-and-forth pulse without needing to author the reverse motion explicitly in the keyframes themselves.
Pausing via animation-play-state
.marquee { animation: scroll 10s linear infinite; }
.marquee:hover { animation-play-state: paused; }A clean, JS-free way to pause a running animation on hover (or via a toggled class from JS), the animation resumes exactly where it left off rather than restarting.
Listening for animation events
element.addEventListener("animationstart", handler);
element.addEventListener("animationend", handler);
element.addEventListener("animationiteration", handler); // fires on each loop of a repeating animationCombining multiple animations
.element {
animation: fadeIn 0.5s ease-out forwards, slideUp 0.5s ease-out forwards;
}Multiple animations (or a mix of transition + animation) can run simultaneously on the same element, comma-separated exactly like the transition shorthand supports multiple properties.
Common Mistakes
1. Forgetting animation-fill-mode: forwards
Covered above, far and away the most common animation bug: an entrance animation plays correctly, then the element instantly reverts to its pre-animation (usually invisible/offset) state the moment the animation completes, because nothing told the browser to retain the final keyframe.
2. Animating layout-affecting properties instead of transform/opacity
The same performance principle from Transitions applies identically to @keyframes, animating width, top, margin, etc. inside keyframes triggers layout recalculation every frame, same as with transitions. Express keyframe motion via transform/opacity wherever the visual result allows it.
3. Using infinite animations without considering prefers-reduced-motion or battery impact
An infinitely looping animation runs forever, consuming CPU/GPU/battery for as long as the element exists on the page, even off-screen or in a backgrounded tab in some cases, pair long-running/looping animations with prefers-reduced-motion handling, and consider pausing animations for elements that scroll out of view (via IntersectionObserver) if they're numerous or expensive.
4. Restarting a CSS animation via the same class toggle trick without understanding why it doesn't work
// Doesn't restart the animation, the class never actually "changed" from the browser's perspective
element.classList.remove("animate");
element.classList.add("animate");// Works, forces a reflow between remove and re-add, so the browser registers the animation as genuinely restarting
element.classList.remove("animate");
void element.offsetWidth; // force reflow
element.classList.add("animate");If the class re-add happens in the same synchronous tick as the removal (very common when done back-to-back in JS), the browser may batch/optimize it away entirely, since from a style-computation perspective nothing appears to have changed between two synchronous mutations. Forcing a reflow (reading a layout-triggering property like offsetWidth) in between forces the browser to acknowledge the intermediate "no animation" state before the class is re-applied.
5. Overusing dramatic/attention-grabbing animation for routine UI
Constant bouncing, spinning, or pulsing elements for non-critical UI create visual noise and can be genuinely distracting or fatiguing, reserve strong motion for moments that deserve real attention (a success confirmation, a critical alert), and keep routine UI (hover states, minor transitions) subtle.
Best Practices
- Always pair a one-shot entrance/exit
@keyframesanimation with the correctanimation-fill-mode(forwardsfor entrances,backwards/bothif there's a delay). - Animate
transform/opacityinside keyframes for the same compositor-thread performance reasons as transitions. - Use
animation-play-state: pausedon:hover/:focusfor a clean way to pause decorative looping animations without JS. - Force a reflow between class removal and re-addition if you need to restart the same CSS animation programmatically.
- Respect
prefers-reduced-motion, especially for large-scale or infinitely-looping animations. - Reserve strong/attention-grabbing motion for moments that warrant it, most UI motion should be subtle.
- Consider pausing expensive off-screen looping animations via
IntersectionObserverif there are many on a page.
Further Resources
- MDN, CSS Animations
- MDN,
@keyframes - MDN,
animation-fill-mode - web.dev, Animations guide
- Animista, library of ready-made, customizable CSS keyframe animations.
- CSS-Tricks, Restart a CSS Animation
