What CSS animations are (and what they aren’t)

A CSS animation lets you change CSS values over time. That can mean moving an element, fading it in and out, changing its color, scaling it up, rotating it… basically anything you can animate smoothly (or in steps).

There are two main “motion tools” in CSS:

  • Transitions animate when something changes (like on :hover).
  • Animations run on their own timeline using @keyframes.

In this tutorial we focus on CSS animations. Learn more about transitions in the CSS Transition Interactive Tutorial.

The two pieces: @keyframes and animation properties

Every CSS animation is made of two parts:

  1. A keyframes definition that describes what changes over time. That’s the @keyframes rule.
  2. The animation properties that tell an element which keyframes to use, how long to run, how many times to repeat, etc.

Think of @keyframes as the recipe and animation-* as the instructions for how to cook it.

 .box { animation-name: slide-right; animation-duration: 1.5s; animation-iteration-count: infinite; }

@keyframes slide-right {
from {
transform: translateX(0);
}

to {
transform: translateX(220px);
}
}
 *, ::before, ::after { box-sizing: border-box; } .stage { width: 320px; border: 3px solid #111; padding: 18px; background: #f2f2f2; } .box { width: 60px; height: 60px; border: 3px solid #111; background: #fff; display: grid; place-items: center; font-family: ui-monospace, SFMono-Regular, monospace; } 
 
Hi

What happened?

  • The element gets animation-name: slide-right; so it knows which keyframes to use.
  • The keyframes named slide-right describe the motion: from translateX(0) to translateX(220px).
  • We loop forever using animation-iteration-count: infinite;.

Your first real animation: duration, iteration-count, and direction

Let’s build up the idea slowly. Here are a few variations, using the same element and stage. Click the different CSS snippets to see how each option changes the motion.

 .box { animation-name: slide-right; animation-duration: 2s; animation-iteration-count: infinite; animation-direction: normal; }

@keyframes slide-right {
from {
transform: translateX(0);
}

to {
transform: translateX(220px);
}
}
 .box { animation-name: slide-right; animation-duration: 0.8s; animation-iteration-count: infinite; animation-direction: normal; } @keyframes slide-right { from { transform: translateX(0); } to { transform: translateX(220px); } } 
 .box { animation-name: slide-right; animation-duration: 2s; animation-iteration-count: infinite; animation-direction: alternate; } @keyframes slide-right { from { transform: translateX(0); } to { transform: translateX(220px); } } 
 *, ::before, ::after { box-sizing: border-box; } .stage { width: 320px; border: 3px solid #111; padding: 18px; background: #f2f2f2; } .box { width: 60px; height: 60px; border: 3px solid #111; background: #fff; display: grid; place-items: center; font-family: ui-monospace, SFMono-Regular, monospace; } 
 
Box

Key takeaways:

  • Duration controls speed. Smaller duration = faster animation.
  • Direction controls how it plays:
    • normal: goes from start to end, then jumps back to start.
    • alternate: goes forward, then backward, then forward… smoother for looping.

Keyframes: from/to, percentages, and multiple stops

You already saw from and to. Those are just shortcuts for:

  • from = 0%
  • to = 100%

Percentages are handy when you want multiple “moments” in your animation, like “pause halfway” or “overshoot then settle”.

 .dot { animation-name: hop; animation-duration: 1.4s; animation-iteration-count: infinite; animation-direction: normal; }

@keyframes hop {
0% {
transform: translateY(0);
}

35% {
transform: translateY(-80px);
}

60% {
transform: translateY(0);
}

100% {
transform: translateY(0);
}
}
 .dot { animation-name: hop; animation-duration: 1.4s; animation-iteration-count: infinite; animation-direction: normal; } @keyframes hop { 0% { transform: translateY(0); } 50% { transform: translateY(-80px); } 100% { transform: translateY(0); } } 
 *, ::before, ::after { box-sizing: border-box; } .stage { width: 320px; height: 180px; border: 3px solid #111; padding: 18px; background: #f2f2f2; display: grid; align-items: end; } .dot { width: 56px; height: 56px; border: 3px solid #111; background: #fff; border-radius: 999px; display: grid; place-items: center; font-family: ui-monospace, SFMono-Regular, monospace; } 
 
Hop

Notice how adding extra stops can create a “pause” feeling, because the value stays the same for a while.

animation-timing-function: the “feel” of speed

Even with the same duration, animations can feel totally different depending on animation-timing-function. This property describes how time is distributed between the start and end.

  • linear: constant speed (robot mode).
  • ease: starts slow, speeds up, slows down (default-ish).
  • ease-in: starts slow, ends fast.
  • ease-out: starts fast, ends slow.
  • ease-in-out: slow start and slow end.
  • steps(n): jumps in chunks (useful for sprite sheets or “8-bit” motion).
animation-timing-function:
 .box { animation-name: slide-right; animation-duration: 1.8s; animation-iteration-count: infinite; animation-direction: alternate; animation-timing-function: linear; } @keyframes slide-right { from { transform: translateX(0); } to { transform: translateX(220px); } } 
 *, ::before, ::after { box-sizing: border-box; } .stage { width: 320px; border: 3px solid #111; padding: 18px; background: #f2f2f2; } .box { width: 60px; height: 60px; border: 3px solid #111; background: #fff; display: grid; place-items: center; font-family: ui-monospace, SFMono-Regular, monospace; } 
 
Go

If you try steps(6), you’ll see the box “teleport” in chunks instead of moving smoothly. That’s not a bug. That’s the point.

animation-delay and animation-fill-mode: why “nothing happens” sometimes

Two properties confuse beginners a lot:

  • animation-delay: waits before starting.
  • animation-fill-mode: controls what styles apply before it starts and after it ends.

If you add a delay, your animation might look broken because it’s just… waiting politely. Fill mode lets you decide what the element looks like during that wait.

 .badge { animation-name: fade-in; animation-duration: 2s; animation-delay: 1.2s; animation-iteration-count: infinite; animation-direction: normal; animation-fill-mode: none; }

@keyframes fade-in {
from {
opacity: 0;
}

to {
opacity: 1;
}
}
 .badge { animation-name: fade-in; animation-duration: 2s; animation-delay: 1.2s; animation-iteration-count: infinite; animation-direction: normal; animation-fill-mode: backwards; } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } 
 .badge { animation-name: fade-in; animation-duration: 2s; animation-delay: 1.2s; animation-iteration-count: infinite; animation-direction: alternate; animation-fill-mode: both; } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } 
 *, ::before, ::after { box-sizing: border-box; } .wrap { width: 320px; border: 3px solid #111; padding: 18px; background: #f2f2f2; display: grid; gap: 12px; font-family: system-ui, sans-serif; } .badge { width: fit-content; padding: 10px 14px; border: 3px solid #111; background: #fff; font-weight: 700; } 
 
I appear… eventually

Try switching snippets and watch what happens during the delay.

Quick guide to fill modes:

  • none: don’t apply keyframe styles outside the animation timeline.
  • backwards: apply the from (0%) styles during the delay.
  • forwards: keep the final keyframe styles after the animation ends (useful when it runs once).
  • both: backwards + forwards.

Animating multiple things: transform, opacity, and color

A keyframe can change multiple properties at once. This is how you get those nice “move + fade + scale” animations.

Also: transform is animation-friendly. It’s one of the best properties to animate for smooth performance.

 .card { animation-name: floaty; animation-duration: 2.2s; animation-iteration-count: infinite; animation-direction: alternate; }

@keyframes floaty {
0% {
transform: translateY(0) scale(1);
opacity: 0.7;
background: #fff;
}

100% {
transform: translateY(-18px) scale(1.06);
opacity: 1;
background: #f7f7f7;
}
}
 .card { animation-name: floaty; animation-duration: 2.2s; animation-iteration-count: infinite; animation-direction: alternate; } @keyframes floaty { 0% { transform: translateY(0) rotate(0); opacity: 1; background: #fff; } 100% { transform: translateY(-18px) rotate(4deg); opacity: 0.75; background: #f7f7f7; } } 
 *, ::before, ::after { box-sizing: border-box; } .stage { width: 320px; height: 220px; border: 3px solid #111; padding: 18px; background: #f2f2f2; display: grid; place-items: center; } .card { width: 220px; height: 120px; border: 3px solid #111; display: grid; place-items: center; font-family: ui-monospace, SFMono-Regular, monospace; font-weight: 700; } 
 
Floaty

Notice how we can animate transform with multiple functions like translateY(), scale(), and rotate() inside a single property.

Shorthand: the animation property (one-line version)

CSS has a shorthand property called animation that can replace most of the long form:

  • animation-name
  • animation-duration
  • animation-timing-function
  • animation-delay
  • animation-iteration-count
  • animation-direction
  • animation-fill-mode
  • animation-play-state

The shorthand is convenient, but as a beginner, it can also feel like a magic spell. So we’ll compare both styles.

 .pill { animation-name: wiggle; animation-duration: 0.9s; animation-timing-function: ease-in-out; animation-iteration-count: infinite; animation-direction: alternate; }

@keyframes wiggle {
from {
transform: rotate(-6deg);
}

to {
transform: rotate(6deg);
}
}
 .pill { animation: wiggle 0.9s ease-in-out infinite alternate; } @keyframes wiggle { from { transform: rotate(-6deg); } to { transform: rotate(6deg); } } 
 *, ::before, ::after { box-sizing: border-box; } .stage { width: 320px; border: 3px solid #111; padding: 18px; background: #f2f2f2; display: grid; place-items: center; } .pill { width: 220px; padding: 12px 14px; border: 3px solid #111; background: #fff; border-radius: 999px; font-family: system-ui, sans-serif; font-weight: 700; text-align: center; } 
 
I am wiggly

If you’re unsure, use the long form first. Once it “clicks”, the shorthand becomes a nice convenience.

Multiple animations on one element

Yes, you can run more than one animation at the same time. You do it by listing multiple animations, separated by commas.

The only rule: if you list multiple names, you usually also list multiple durations (and so on).

 .badge { animation-name: pulse, drift; animation-duration: 1.1s, 3s; animation-timing-function: ease-in-out, linear; animation-iteration-count: infinite, infinite; animation-direction: alternate, alternate; }

@keyframes pulse {
from {
transform: scale(1);
}

to {
transform: scale(1.12);
}
}

@keyframes drift {
from {
translate: 0 0;
}

to {
translate: 18px -10px;
}
}
 *, ::before, ::after { box-sizing: border-box; } .stage { width: 320px; height: 200px; border: 3px solid #111; padding: 18px; background: #f2f2f2; display: grid; place-items: center; } .badge { width: 160px; height: 80px; border: 3px solid #111; background: #fff; display: grid; place-items: center; font-family: ui-monospace, SFMono-Regular, monospace; font-weight: 700; } 
 
Two at once

Here, pulse handles the scaling, while drift handles the gentle movement. Together: a floating, breathing badge.

Hover animations vs always-running animations

Many real UI animations happen on :hover (or focus) rather than running forever. When an animation only makes sense as feedback, hover is perfect.

 .button:hover { animation-name: pop; animation-duration: 0.35s; animation-iteration-count: 1; }

@keyframes pop {
0% {
transform: scale(1);
}

60% {
transform: scale(1.12);
}

100% {
transform: scale(1);
}
}
 .button:hover { animation-name: pop; animation-duration: 0.5s; animation-iteration-count: 1; animation-timing-function: ease-in-out; } @keyframes pop { 0% { transform: scale(1); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } } 
 *, ::before, ::after { box-sizing: border-box; } .stage { width: 320px; border: 3px solid #111; padding: 18px; background: #f2f2f2; display: grid; place-items: center; } .button { border: 3px solid #111; background: #fff; padding: 12px 16px; font-family: system-ui, sans-serif; font-weight: 800; cursor: pointer; border-radius: 12px; } 
 

Hover animations are great when you want motion to be meaningful. Nobody wants a button that wiggles forever like it drank three coffees.

animation-play-state and prefers-reduced-motion

Sometimes you want to pause an animation. That’s what animation-play-state does:

  • running: the animation plays (default).
  • paused: the animation freezes on the current frame.

Also, it’s good practice to respect users who prefer reduced motion. CSS provides a media query for that: @media (prefers-reduced-motion: reduce).

 .loader { animation-name: spin; animation-duration: 1s; animation-iteration-count: infinite; animation-timing-function: linear; animation-play-state: running; }

.stage:hover .loader {
animation-play-state: paused;
}

@keyframes spin {
from {
transform: rotate(0);
}

to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) { .loader { animation: none; } } 
 *, ::before, ::after { box-sizing: border-box; } .stage { width: 320px; border: 3px solid #111; padding: 18px; background: #f2f2f2; display: grid; place-items: center; gap: 10px; font-family: system-ui, sans-serif; } .loader { width: 70px; height: 70px; border: 3px solid #111; border-radius: 999px; position: relative; background: #fff; } .loader::after { content: ""; position: absolute; width: 12px; height: 12px; border: 3px solid #111; border-radius: 999px; background: #fff; top: -10px; left: 50%; transform: translateX(-50%); } 
 

Hover the box to pause the spinner.

Common gotchas (and a tiny checklist)

If your animation isn’t working, it’s usually one of these:

  • You forgot animation-name or the name doesn’t match the @keyframes name.
  • You forgot animation-duration. Without it, the animation is effectively 0s.
  • Your element is moving, but you can’t tell because there’s no space (try a bordered “stage” container).
  • You added animation-delay and assumed it was broken (it’s just waiting).
  • You’re animating something hard to animate (start with transform and opacity).

Quick checklist:

  1. Do I have a matching @keyframes name?
  2. Did I set animation-duration?
  3. Can I clearly see the change happening?
  4. Is there a delay making it look idle?

CSS AnimationConclusion

You now know the full CSS animation “core loop”: define motion with @keyframes, then control how it runs with animation properties like duration, timing function, direction, delay, and fill mode.