CSS Animations and @keyframes: Spinners, Pulses and Loaders
Create looping, multi-step animations with @keyframes and animation: spinners, pulses, bounces, staggered reveals, progress bars and skeleton loaders.
What you will learn
A transition moves from one state to another when something triggers it, such as a hover. A keyframe animation is more powerful: it can start by itself, pass through many steps, loop forever, run backwards, and be paused. It powers loading spinners, attention-grabbing pulses, entrance effects and skeleton loaders. In this lesson you will learn how @keyframes defines an animation, how the animation property applies it, and how to control the repeat count, direction, delay and fill mode, with six real examples and the habits that keep animation accessible and fast.
The HTML we will style
<h1 class="page-title">Animation Lab</h1>
<!-- Part 1: a spinner that rotates forever -->
<div class="spinner" role="status" aria-label="Loading"></div>
<!-- Part 2: a pulsing notification dot -->
<p class="notice"><span class="dot"></span> 3 new messages</p>
<!-- Part 3: a bouncing ball -->
<div class="stage">
<div class="ball"></div>
</div>
<!-- Part 4: items that fade in one after another -->
<ul class="reveal-list">
<li class="reveal reveal-1">First item</li>
<li class="reveal reveal-2">Second item</li>
<li class="reveal reveal-3">Third item</li>
</ul>
<!-- Part 5: a progress bar that fills once -->
<div class="progress">
<div class="progress-bar"></div>
</div>
<!-- Part 6: a loading placeholder (skeleton) -->
<div class="skeleton-card">
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-line"></div>
<div class="skeleton skeleton-line skeleton-short"></div>
</div>
| Selector | Element | Animation |
|---|---|---|
.spinner |
an empty <div> (with role="status" for screen readers) |
spin: rotates forever |
.notice and .dot |
a paragraph and the small <span> inside it |
pulse: the dot grows and fades, forever |
.stage and .ball |
a tall box and the ball inside it | bounce: the ball goes up and down |
.reveal-list, .reveal |
the <ul> and all three <li> |
fade-up: each item fades in and slides up |
.reveal-1, .reveal-2, .reveal-3 |
each <li> |
a different delay to stagger them |
.progress and .progress-bar |
the track and the fill | fill: the bar grows once |
.skeleton-card |
the wrapper | holds the placeholder lines |
.skeleton, .skeleton-title, .skeleton-line, .skeleton-short |
the three empty <div>s |
shimmer: a moving highlight |
Transitions vs animations
| Transition | Animation | |
|---|---|---|
| Needs a trigger (hover, class change) | Yes | No, it can start by itself |
| Steps | Two: from A to B | As many as you like |
| Repeats | No | Yes, any number of times or forever |
| Runs backwards or alternates | Only by reversing the trigger | Built in |
| Best for | hover and focus effects, state changes | loaders, entrances, attention effects, ongoing motion |
Use a transition when something simply changes from one state to another. Use an animation when you need steps, repetition or automatic playback.
Step 1: define the animation with @keyframes
@keyframes gives an animation a name and describes what the element looks like at different moments:
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
spinis the name. You choose it (letters, digits, hyphens, no spaces).fromis the start (0%),tois the end (100%).- Inside each step you write ordinary CSS declarations, and the browser fills in all the frames between them.
For more than two steps, use percentages of the total time:
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.5; }
100% { transform: scale(1); opacity: 1; }
}
You can write steps as a group: 0%, 100% { ... } means the same style at the start and end, which is perfect for animations that return to where they began.
A defined animation does nothing by itself.
@keyframesis only a recipe. It runs when an element uses it with theanimationproperty.
Step 2: apply it with animation
/* the <div class="spinner"> */
.spinner {
width: 48px;
height: 48px;
border: 5px solid #e2e8f0;
border-top-color: royalblue; /* one coloured edge, so the rotation is visible */
border-radius: 50%;
animation: spin 1s linear infinite;
}
The shorthand reads: name, duration, timing function, iteration count. The full order of values is:
animation: name duration timing-function delay iteration-count direction fill-mode play-state;
/* e.g. spin 1s linear 0s infinite normal none running */
| Longhand | Meaning | Common values |
|---|---|---|
animation-name |
which @keyframes to use |
spin |
animation-duration |
one cycle’s length | 1s, 300ms |
animation-timing-function |
speed curve (same values as transitions) | ease, linear, ease-in-out, steps(5) |
animation-delay |
wait before starting | 0.5s; a negative value starts partway through |
animation-iteration-count |
how many times | 1 (default), 3, infinite |
animation-direction |
which way each cycle runs | normal, reverse, alternate, alternate-reverse |
animation-fill-mode |
what the element looks like before and after | none, forwards, backwards, both |
animation-play-state |
pause or run | running, paused |
The first time value is the duration, and the second is the delay.
Example 1: the spinner
A spinner is the simplest loop: rotate 360 degrees, forever, at constant speed.
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}
linear matters here. With the default ease, the spinner would slow down at the end of every rotation and appear to stutter. For anything that loops continuously, linear is usually right.
Example 2: the pulse
/* the <span class="dot"> inside <p class="notice"> */
.dot {
display: inline-block; /* transforms need a non-inline element */
width: 12px;
height: 12px;
border-radius: 50%;
background-color: crimson;
animation: pulse 1.5s ease-in-out infinite;
}
The pulse keyframes (shown above) scale the dot up to 1.5 times its size and back, while fading it halfway. Because the dot returns to its start, the loop is seamless.
Example 3: the bounce
A bouncing ball feels natural when it slows down as it rises and speeds up as it falls. A different timing function in each keyframe does exactly that:
@keyframes bounce {
0% {
transform: translateY(0);
animation-timing-function: ease-out; /* leaving the ground: fast, then slowing */
}
50% {
transform: translateY(-120px);
animation-timing-function: ease-in; /* falling: slow, then faster */
}
100% {
transform: translateY(0);
}
}
/* the <div class="stage"> holds the ball, the <div class="ball"> is the ball */
.stage {
height: 160px;
padding-top: 130px;
margin-bottom: 32px;
}
.ball {
width: 30px;
height: 30px;
border-radius: 50%;
background-color: tomato;
animation: bounce 1s infinite;
}
animation-timing-function placed inside a keyframe controls the speed from that keyframe to the next one. That is the secret of realistic movement.
Example 4: staggered entrance with delay and fill mode
Items that appear one after another look more polished than all at once. You need a shared animation, a different delay for each item, and the right fill mode:
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* all three <li class="reveal"> */
.reveal {
animation: fade-up 0.5s ease both;
}
.reveal-1 { animation-delay: 0s; }
.reveal-2 { animation-delay: 0.15s; }
.reveal-3 { animation-delay: 0.3s; }
Why both? animation-fill-mode decides what the element looks like when the animation is not running:
| Value | Before it starts (during the delay) | After it ends |
|---|---|---|
none (default) |
normal style (the item would be visible, then flash invisible, then fade in) | back to the normal style |
backwards |
holds the from style (hidden during the delay) |
back to the normal style |
forwards |
normal style | stays on the to style |
both |
holds the from style |
stays on the to style |
Without backwards, the second and third items would show for a moment, then vanish, then fade in. With both, they stay hidden until their turn.
This runs once, when the page loads. To see it again, reload the page, or click Practice in Editor again. Entrance animations that must run on a user action need the class to be added with JavaScript.
Example 5: the progress bar (forwards)
@keyframes fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* the track: <div class="progress"> */
.progress {
height: 12px;
overflow: hidden;
border-radius: 999px;
background-color: #e2e8f0;
margin-bottom: 32px;
}
/* the fill: <div class="progress-bar"> */
.progress-bar {
height: 100%;
background-color: seagreen;
transform-origin: left; /* grow from the left edge */
animation: fill 3s ease-out forwards; /* play once, then stay full */
}
Two points: forwards makes the bar stay at its final state after the 3 seconds. And we animate transform: scaleX() rather than width, because transforms are cheaper for the browser. transform-origin: left makes the bar grow from the left instead of from the middle.
Example 6: the skeleton loader
A skeleton is a grey placeholder shaped like the content that is loading, with a soft highlight sweeping across it:
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* all three <div class="skeleton"> */
.skeleton {
border-radius: 6px;
background: linear-gradient(90deg, #e2e8f0 25%, #f8fafc 50%, #e2e8f0 75%);
background-size: 200% 100%; /* the gradient is twice as wide as the box */
animation: shimmer 1.5s linear infinite;
}
.skeleton-title { height: 24px; width: 60%; margin-bottom: 12px; }
.skeleton-line { height: 14px; margin-bottom: 8px; }
.skeleton-short { width: 40%; }
The trick: the gradient is stretched to twice the width of the element, and the animation slides its position, so the light band travels across. The elements are empty <div>s. Their size and look come entirely from CSS.
Controlling an animation
Direction
animation: bounce 1s ease-in-out infinite alternate;
alternate plays forward, then backwards, then forwards again. It is a cheap way to make a smooth back-and-forth from a simple from/to pair. reverse plays backwards each time.
Count
animation-iteration-count: 3 plays three times and stops. Decimal numbers are allowed (1.5).
Pausing
.spinner:hover {
animation-play-state: paused;
}
The animation freezes where it is and resumes from there. This is handy for carousels and tickers that people want to read.
Negative delay
A negative animation-delay starts the animation as though it had already been running. It is useful for making several looping elements look out of sync from the first frame:
.dot { animation-delay: -0.5s; } /* starts half a second "into" the pulse */
Several animations at once
Separate them with commas:
.reveal-3 {
animation:
fade-up 0.5s ease both,
pulse 2s ease-in-out 1s infinite;
}
The third list item fades in first, then after a one-second delay it starts pulsing.
Performance and accessibility
- Animate
transformandopacitywhenever you can. They are cheap. Animatingwidth,height,top,leftormarginforces the browser to recalculate the layout on every frame. (The shimmer usesbackground-position, which is acceptable for small areas, but avoid it on huge ones.) - Do not animate lots of things at once, especially large or blurred ones, on pages that must run on cheap phones.
- Respect reduced motion. Some visitors feel ill from animated movement. Switch off non-essential animation for them:
@media (prefers-reduced-motion: reduce) {
.reveal,
.progress-bar,
.ball,
.dot,
.skeleton {
animation: none;
}
}
Elements fall back to their normal, static style: the list items are visible, the progress bar is full. Keep small, functional motion (like a spinner showing that something is loading) or replace it with something static such as text.
- Avoid flashing. Content must not flash more than three times per second, as this can trigger seizures.
- Let people stop long-running motion. Anything that moves automatically for more than about five seconds, such as a ticker or carousel, should offer a way to pause it.
- Do not put important information only in motion.
Putting it together
*, *::before, *::after { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; }
/* Part 1: spinner */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
width: 48px;
height: 48px;
margin-bottom: 32px;
border: 5px solid #e2e8f0;
border-top-color: royalblue;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner:hover { animation-play-state: paused; }
/* Part 2: pulse */
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.5; }
}
.dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: crimson;
animation: pulse 1.5s ease-in-out infinite;
}
/* Part 3: bounce */
@keyframes bounce {
0% { transform: translateY(0); animation-timing-function: ease-out; }
50% { transform: translateY(-120px); animation-timing-function: ease-in; }
100% { transform: translateY(0); }
}
.stage { height: 160px; padding-top: 130px; margin-bottom: 32px; }
.ball { width: 30px; height: 30px; border-radius: 50%; background-color: tomato; animation: bounce 1s infinite; }
/* Part 4: staggered entrance */
@keyframes fade-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal { animation: fade-up 0.5s ease both; }
.reveal-1 { animation-delay: 0s; }
.reveal-2 { animation-delay: 0.15s; }
.reveal-3 { animation-delay: 0.3s; }
/* Part 5: progress bar */
@keyframes fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress { height: 12px; overflow: hidden; border-radius: 999px; background-color: #e2e8f0; margin-bottom: 32px; }
.progress-bar { height: 100%; background-color: seagreen; transform-origin: left; animation: fill 3s ease-out forwards; }
/* Part 6: skeleton */
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
.skeleton {
border-radius: 6px;
background: linear-gradient(90deg, #e2e8f0 25%, #f8fafc 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
.skeleton-title { height: 24px; width: 60%; margin-bottom: 12px; }
.skeleton-line { height: 14px; margin-bottom: 8px; }
.skeleton-short { width: 40%; }
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.reveal, .progress-bar, .ball, .dot, .skeleton { animation: none; }
}
Common mistakes
- Defining
@keyframesbut never applying them withanimation, or the other way round with a name that does not match exactly. - Forgetting the duration. The default is
0s, so nothing visibly happens. - Typos in the animation name, which silently do nothing. Names are case-sensitive.
- Using
easeon a continuous loop, so it stutters at each cycle. Uselinearfor spinners. - Animating an inline element (a
<span>withdisplay: inline) with a transform. Make itinline-block. - Leaving out
fill-modeon entrance animations, so items flash before they animate, or snap back at the end. - Overwriting the element’s own
transform. An animatedtransformreplaces any statictransformon the same element while it runs. - Animating layout properties (
width,top) on many elements. - Ignoring
prefers-reduced-motion, or making things flash. - Starting an animation on page load and expecting it to replay, when it plays only once.
- Decorating everything with motion. Animation should clarify, not distract.
Practice
- Click Practice in Editor. The spinner already works. Change its duration to
0.4s, then3s. Then changelineartoeaseand watch it stutter. - Make the spinner pause on hover with
animation-play-state. - Create the pulsing dot with three keyframe steps. Then give a second dot a negative
animation-delayso the two pulses are out of sync. - Build the bounce. First leave out the per-keyframe
animation-timing-function, then add it. Which feels more natural? - Make the three list items fade in one after another. Remove
bothand describe what you see during the delays. - Fill the progress bar once and keep it full, using
forwards. Then changeforwardstononeand see what happens at the end. - Build the skeleton loader and change the highlight colour. Try
animation-direction: reverse. - Add the
prefers-reduced-motionblock and test it with the DevTools Rendering panel. - Make the bounce ball use
animation-iteration-count: 3so it stops after three bounces. Where does it end up?
Recap
- A keyframe animation has two parts:
@keyframes name { ... }describes the steps (from/toor percentages), andanimation: name duration ...applies it to an element. - The shorthand order is name, duration, timing function, delay, iteration count, direction, fill mode, play state.
- Use
infinitefor loops,alternatefor back-and-forth,linearfor continuous motion, andanimation-timing-functioninside a keyframe for realistic movement. animation-fill-modecontrols the look before and after:backwardsholds the first step during a delay,forwardskeeps the last step,bothdoes both.- Pause with
animation-play-state: paused, stagger with different delays, and combine several animations with commas. - Animate
transformandopacity, respectprefers-reduced-motion, avoid flashing, and use motion sparingly. - Next you will learn the modern selectors
:is(),:where()and:has(), which make CSS shorter and more powerful.