A CSS keyframe animation is a set of named intermediate steps that the browser walks through on its own. Unlike a transition, it does not need a state change to start, it can hold at a step, pause, reverse or repeat, and it can animate custom properties as well as ordinary ones.
<article class="cover">
<div class="skeleton" aria-hidden="true"></div>
<h3 class="cover__title">Slow Harbour</h3>
<div class="equalizer" aria-hidden="true">
<span></span><span></span><span></span><span></span>
</div>
</article>
<p class="toast" role="status">Added Slow Harbour to your queue</p>
What does an @keyframes rule actually contain?
A keyframes rule is a named list of stages. Each stage is written with from, to or a percentage, and holds the property values that should apply at that point in the animation.
@keyframes notice {
0% { transform: translateY(-16px); opacity: 0; }
12% { transform: translateY(0); opacity: 1; }
85% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(-16px); opacity: 0; }
}
.toast {
animation: notice 6s ease-in-out 1 both;
}
Properties you leave out of a stage are interpolated from whatever value they had underneath, so you only need to write the stages that actually differ. If you omit 0 percent or 100 percent, the browser uses the element's underlying value at that end, which is how an animation can fade out of whatever state the element was already in.
The percentages do not have to be evenly spaced. That is the whole point of a multi-step animation: you can spend 12 percent of the duration arriving, hold still for 73 percent, and leave during the rest.
What do the animation properties control?
.equalizer span {
animation-name: bar;
animation-duration: 900ms;
animation-timing-function: ease-in-out;
animation-delay: 0ms;
animation-iteration-count: infinite;
animation-direction: alternate;
animation-fill-mode: both;
animation-play-state: running;
}
- Name picks the keyframes rule. If it does not match, nothing animates and no error is reported.
- Duration is how long one iteration takes.
- Timing function shapes the progress within a stage. It applies separately to each pair of adjacent stages, not to the animation as a whole.
- Delay waits before the first iteration starts, which is how you stagger a list of items.
- Iteration count can be a number or
infinite. - Direction plays the stages forwards, backwards, or alternating between them.
- Fill mode decides what happens outside the run:
forwardskeeps the final values,backwardsapplies the first values during the delay,bothdoes each. - Play state pauses or resumes a running animation, which is exactly what a pause button needs.
The shorthand is animation: name duration timing-function delay iteration-count direction fill-mode play-state, and anything you leave out falls back to its default.
How do you build multi-step motion?
Looping feedback indicators are the classic case. A skeleton placeholder fades a highlight across itself, and a row of bars in an equaliser bounces at different rates.
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton {
height: 1rem;
border-radius: 6px;
background-color: #e8ecf1;
background-image: linear-gradient(90deg, #e8ecf1 25%, #f6f8fa 37%, #e8ecf1 63%);
background-size: 400% 100%;
animation: shimmer 1.4s ease-in-out infinite;
}
@keyframes bar {
0%, 100% { transform: scaleY(0.3); }
50% { transform: scaleY(1); }
}
.equalizer {
display: flex;
gap: 3px;
height: 24px;
align-items: flex-end;
}
.equalizer span {
width: 4px;
height: 100%;
background: currentColor;
transform-origin: bottom;
}
.equalizer span:nth-child(2) { animation-delay: 160ms; }
.equalizer span:nth-child(3) { animation-delay: 320ms; }
The animation-delay on each bar is what makes the group look alive rather than mechanical, and staggering a longer list is the same technique with a different value per item.
How do you animate a custom property?
By default, a custom property holds an arbitrary token stream, and the browser has no idea how to interpolate one, so an animated custom property jumps from one value to another halfway through. Registering the property fixes that by giving it a type.
@property --glow {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@keyframes glow {
from { --glow: 0; }
to { --glow: 1; }
}
.cover {
--glow: 0;
animation: glow 2s ease-in-out infinite alternate;
box-shadow: 0 0 calc(10px + 20px * var(--glow)) rgb(99 102 241 / 0.4);
}
With the property registered as a number, the browser interpolates it smoothly and the shadow grows and shrinks as one continuous motion. The syntax descriptor is what makes this work, and it also means a value that does not parse as a number is rejected and replaced by the initial value instead of breaking the rule.
How do you honour reduced motion?
Some people get motion sickness from movement that they did not ask for, and operating systems expose a setting for that. Honouring it is a short block of CSS.
@media (prefers-reduced-motion: reduce) {
.skeleton,
.equalizer span {
animation: none;
}
.toast {
animation: none;
opacity: 1;
transform: none;
}
}
Targeting the specific animations is better than a blanket reset, because a blanket rule that sets every duration to nearly zero still runs the animation machinery and still produces a flicker. Turning the motion off and leaving the element in its final state keeps the interface usable.
Where browsers support scroll-driven animations, a keyframes rule can also be attached to a scroll position instead of the clock, using animation-timeline, so a panel reveals itself as it comes into view rather than on a timer.
Cheat sheet
- Keyframes are stages; the animation properties decide how the browser walks through them.
- Stages can be unevenly spaced, which is how you get arrival, hold and departure in one animation.
- The easing function applies to each stage interval, so a global curve is repeated between every pair of stages.
- Register a custom property with
@propertybefore animating it, or it will jump instead of glide. - Use
animation-delayto stagger a group, andalternateto ping-pong without writing twice as many stages. - Turn motion off when the reader has asked for it, using
prefers-reduced-motion: reduce.
Try it yourself: Build a now-playing card for a music app. Add a skeleton row that shimmers with a background-position keyframe, four equaliser bars that scale with staggered delays, and a toast that slides in, holds for a few seconds and slides out again. Then add a prefers-reduced-motion: reduce block that removes all three animations, and confirm the card still shows its title, its bar chart and its message when the animations are switched off.