A CSS animation is an automatic sequence of visual changes defined on a timeline.
In the previous post we saw transition, which is used to smooth changes between two states. For example, a button that changes color on :hover. That’s fine for simple interactions, but it falls short for more complex animations.
When we need autonomous movements, loops, entrance scenes, or changes with multiple intermediate steps we have @keyframes and the animation property.
Transition or animation
Before we dive into writing @keyframes like there’s no tomorrow, it’s worth distinguishing between concepts.
- A transition needs a state change:
:hover, a new class, afocus, etc. - An animation can run on its own, repeat, go forwards and backwards, or have many steps.
/* Transition: waits for something to change */
.button {
transition: transform 0.2s ease;
}
.button:hover {
transform: translateY(-2px);
}
/* Animation: has its own timeline */
.indicator {
animation: blink 1s infinite;
}
Transitions are perfect for micro-interactions. Animations are better when the movement has a life of its own.
Defining the animation with @keyframes
The @keyframes rule defines the important moments of an animation. It’s like writing the script for what will happen.
@keyframes heartbeat {
0% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
The browser automatically calculates the intermediate values between those points. We mark the keyframes, and it handles drawing the path.
0% and 100% can also be written as from and to. For two-state animations, it’s very clean.
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Applying the animation with animation
Defining @keyframes doesn’t do anything by itself. We need to associate that animation with an element using the animation property.
.heart {
animation: heartbeat 2s ease-in-out infinite;
}
This property is a shorthand that can include many values in a single line.
animation-name: name of the@keyframes.animation-duration: duration of one cycle.animation-timing-function: acceleration curve.animation-delay: delay before starting.animation-iteration-count: number of repetitions.animation-direction: playback direction.animation-fill-mode: state retained before or after animation.
.heart {
/* name | duration | curve | delay | repetitions | direction */
animation: heartbeat 2s ease-in-out 0s infinite normal;
}
If that line seems too compressed, you can write it with longhand properties.
.heart {
animation-name: heartbeat;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-delay: 0s;
animation-iteration-count: infinite;
animation-direction: normal;
}
Repetitions and direction
The animation-iteration-count property indicates how many times the animation repeats.
.alert {
animation: shake 0.4s ease 3;
}
.loading {
animation: spin 1s linear infinite;
}
infinite creates an infinite loop. Use it with care, because a constant animation draws a lot of attention. If everything moves all the time, the page looks nervous.
The animation-direction property controls the playback direction.
.ball {
animation: bounce 1s ease-in-out infinite alternate;
}
With alternate, the animation goes from 0% to 100% and then back from 100% to 0%. It’s perfect for back-and-forth movements.
animation-fill-mode
By default, when an animation finishes, the element returns to its original style. Sometimes that’s exactly what we want. Other times it looks terrible, because the element appears smoothly… and then jumps back at the end.
To control this, we use animation-fill-mode.
.modal {
animation: fadeInUp 0.25s ease-out forwards;
}
forwards makes the element retain the styles of the last keyframe. It’s very useful for entrance animations.
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
If an animation starts with a different visual state, there’s also backwards, which applies the first keyframe during the delay. And both, which combines forwards and backwards.
Chained animations with delays
We can create staggered entries by applying different animation-delay values.
.item {
opacity: 0;
animation: fadeInUp 0.4s ease-out forwards;
}
.item:nth-child(1) {
animation-delay: 0s;
}
.item:nth-child(2) {
animation-delay: 0.1s;
}
.item:nth-child(3) {
animation-delay: 0.2s;
}
The result is a cascading list. Used well, it looks elegant. Used badly, it looks like the interface can’t decide to exist.
Performance: animate cheap properties
Not all properties cost the same to animate. Some force the browser to recalculate the page layout over and over again.
/* Worse: forces layout recalculation */
.box {
animation: grow 1s infinite alternate;
}
@keyframes grow {
to {
width: 400px;
}
}
For smooth animations, try to use transform and opacity.
/* Better: usually runs much smoother */
.box {
animation: slideIn 0.4s ease-out forwards;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-2rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
As a practical rule, animate transform and opacity whenever you can. Avoid animating width, height, margin, top, or left if you’re aiming for smooth movements.
Complete example: loading spinner
A classic CSS animation case is a loading indicator.
<span class="spinner" aria-label="Loading"></span>
.spinner {
display: inline-block;
width: 2rem;
height: 2rem;
border: 0.25rem solid #dbeafe;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
The rotation is done with transform, so it’s a fairly cheap animation for the browser.
