While transitions animate between two states, keyframe animations let you define complex, multi-step sequences. They can run automatically, repeat indefinitely, and create effects that transitions simply cannot.
The @keyframes Syntax
Define an animation sequence with @keyframes, then apply it with the animation property:
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.element {
animation: fade-in 0.3s ease-out;
}For multi-step animations, use percentage keyframes:
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}You can list multiple stops at the same percentage to hold a value:
@keyframes progress {
0%, 10% {
width: 0%;
}
90%, 100% {
width: 100%;
}
}Animation Properties
The animation shorthand combines multiple properties:
.element {
animation: bounce 0.6s ease-in-out 0.2s infinite alternate both;
/* name duration timing delay count direction fill */
}Here is what each property controls:
| Property | Values | Purpose |
|---|---|---|
animation-name | keyframe name | Which animation to run |
animation-duration | time (e.g., 0.3s) | How long one cycle takes |
animation-timing-function | ease, linear, cubic-bezier | Speed curve |
animation-delay | time | Wait before starting |
animation-iteration-count | number or infinite | How many times to repeat |
animation-direction | normal, reverse, alternate | Direction of playback |
animation-fill-mode | none, forwards, backwards, both | What styles apply before/after |
animation-play-state | running, paused | Play or pause |
Fill Modes
Fill mode is critical for one-shot animations. Without it, the element snaps back to its original state when the animation ends:
/* Without fill mode: element disappears then reappears */
@keyframes slide-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
/* With forwards: element stays at the final keyframe */
.panel {
animation: slide-in 0.3s ease-out forwards;
}
/* With both: element starts at the first keyframe (even during delay)
and stays at the last keyframe after completion */
.panel-delayed {
animation: slide-in 0.3s ease-out 0.5s both;
}Use forwards when the animation should stick. Use both when there is a delay and the element should start in the first keyframe state.
Practical Animations
Loading Spinner
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}Skeleton Screen Shimmer
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 0.25rem;
}Staggered List Entry
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.list-item {
animation: slide-up 0.4s ease-out both;
}
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 50ms; }
.list-item:nth-child(3) { animation-delay: 100ms; }
.list-item:nth-child(4) { animation-delay: 150ms; }
.list-item:nth-child(5) { animation-delay: 200ms; }Staggering creates a cascading effect that feels natural and draws the eye down the list. Keep delays short (30-80ms between items) to avoid feeling sluggish.
Notification Entry
@keyframes notification-in {
0% {
transform: translateX(100%);
opacity: 0;
}
60% {
transform: translateX(-5%);
opacity: 1;
}
100% {
transform: translateX(0);
}
}
.notification {
animation: notification-in 0.4s ease-out both;
}The slight overshoot at 60% creates a natural deceleration that catches the user's eye.
Choreography
When multiple elements animate, their timing relationships matter. Choreography is the art of making multiple animations feel coordinated:
/* Container fades in first */
.modal-backdrop {
animation: fade-in 0.2s ease-out both;
}
/* Content slides up slightly after */
.modal-content {
animation: slide-up 0.3s ease-out 0.1s both;
}
/* Close button appears last */
.modal-close {
animation: fade-in 0.2s ease-out 0.25s both;
}Rules for good choreography:
- Container before content — the backdrop or wrapper should appear before its children
- Large movements before small ones — primary elements animate first
- Keep total sequence under 500ms — users should not wait for animations to finish
- Exit animations are faster than entries — when closing, users want it gone quickly
Reducing Motion
Always provide a reduced-motion alternative:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}This global reset disables all animations for users who request it while still allowing animation-fill-mode: forwards to apply final states instantly.