CSSIntermediate

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.

All CSS lessons

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);
  }
}
  • spin is the name. You choose it (letters, digits, hyphens, no spaces).
  • from is the start (0%), to is 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. @keyframes is only a recipe. It runs when an element uses it with the animation property.

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 transform and opacity whenever you can. They are cheap. Animating width, height, top, left or margin forces the browser to recalculate the layout on every frame. (The shimmer uses background-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 @keyframes but never applying them with animation, 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 ease on a continuous loop, so it stutters at each cycle. Use linear for spinners.
  • Animating an inline element (a <span> with display: inline) with a transform. Make it inline-block.
  • Leaving out fill-mode on entrance animations, so items flash before they animate, or snap back at the end.
  • Overwriting the element’s own transform. An animated transform replaces any static transform on 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

  1. Click Practice in Editor. The spinner already works. Change its duration to 0.4s, then 3s. Then change linear to ease and watch it stutter.
  2. Make the spinner pause on hover with animation-play-state.
  3. Create the pulsing dot with three keyframe steps. Then give a second dot a negative animation-delay so the two pulses are out of sync.
  4. Build the bounce. First leave out the per-keyframe animation-timing-function, then add it. Which feels more natural?
  5. Make the three list items fade in one after another. Remove both and describe what you see during the delays.
  6. Fill the progress bar once and keep it full, using forwards. Then change forwards to none and see what happens at the end.
  7. Build the skeleton loader and change the highlight colour. Try animation-direction: reverse.
  8. Add the prefers-reduced-motion block and test it with the DevTools Rendering panel.
  9. Make the bounce ball use animation-iteration-count: 3 so it stops after three bounces. Where does it end up?

Recap

  • A keyframe animation has two parts: @keyframes name { ... } describes the steps (from/to or percentages), and animation: name duration ... applies it to an element.
  • The shorthand order is name, duration, timing function, delay, iteration count, direction, fill mode, play state.
  • Use infinite for loops, alternate for back-and-forth, linear for continuous motion, and animation-timing-function inside a keyframe for realistic movement.
  • animation-fill-mode controls the look before and after: backwards holds the first step during a delay, forwards keeps the last step, both does both.
  • Pause with animation-play-state: paused, stagger with different delays, and combine several animations with commas.
  • Animate transform and opacity, respect prefers-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.