A CSS transition is great when you have a clear “before” and “after” — a button that’s one color at rest and another on hover. But what about motion that doesn’t depend on a state change? A spinner that turns forever. A notification dot that pulses to catch your eye. A loading bar that crawls across the screen the moment the page opens. Transitions can’t do any of that, because there’s no second state to transition to.
That’s where CSS animations come in. Instead of describing two endpoints, you describe a whole timeline — a sequence of keyframes the browser plays through. You can loop it, reverse it, pause it, run several at once, and trigger it without any user interaction at all. And like transitions, it’s pure CSS: no JavaScript, no animation library, just a couple of rules. Let’s build up from the simplest possible animation to the real-world patterns you’ll actually ship.
Transitions vs. animations: which one do you need?
Before the syntax, it’s worth being clear on when to reach for each, because they overlap.
A transition animates between two states and needs a trigger — usually :hover, :focus, or a class toggled by JavaScript. It goes from where you are now to where you told it to go, once.
An animation plays a predefined timeline you author yourself, and it can start on its own, repeat forever, and pass through as many intermediate steps as you like. No trigger and no state change required.
A quick rule of thumb
If the motion is a reaction to something the user did (hover, click, focus), a transition is usually simpler and the right call. If the motion runs on its own, loops, or moves through several stages, you want an animation. Plenty of polished interfaces use both side by side.
Step one: define a @keyframes rule
A CSS animation has two halves. First you define the motion with a @keyframes rule, giving it a name and describing what the element looks like at each point in the timeline. Then you apply it to an element with the animation property. Let’s start with the definition:
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
This says: at the start (from), the element is fully transparent; by the end (to), it’s fully opaque. The name fade-in is something you make up — you’ll use it again in a moment to attach this animation to an element. from and to are just friendly aliases for 0% and 100% of the timeline.
On its own, a @keyframes rule does nothing. It’s a recipe sitting on the shelf. Nothing animates until you actually apply it.
Step two: apply it with the animation property
To run the animation, point an element at it by name and give it a duration:
.box {
animation-name: fade-in;
animation-duration: 1s;
}
Now any element with the class box fades from invisible to visible over one second the moment it appears on the page. Those two lines — which animation and how long it takes — are the bare minimum to get motion on screen. Everything else is fine-tuning.
In practice you’ll almost always use the animation shorthand instead of writing each property out, but seeing them separated first makes the shorthand far less mysterious. We’ll get there shortly.
Percentage keyframes: more than two steps
from/to covers the simple cases, but the real power of @keyframes is that you can define as many in-between steps as you want using percentages. Here’s a bounce:
@keyframes bounce {
0% { transform: translateY(0); }
50% { transform: translateY(-30px); }
100% { transform: translateY(0); }
}
The element starts at its normal position, rises 30 pixels by the halfway point, then drops back down by the end. The browser smoothly interpolates between each stop, so you get a clean up-and-down bounce without describing every single frame yourself — you only mark the turning points.
You can add as many percentage stops as you need. Want a wiggle? Define a few left-and-right positions at 25%, 50%, and 75%. Each percentage is a checkpoint; the browser fills in the motion between them.
Animate transform and opacity for smooth motion
Notice the bounce moves the element with transform: translateY(...) rather than changing top or margin. That’s deliberate. Browsers can animate transform and opacity on the GPU without re-laying-out the page, so they stay buttery smooth. Animating layout properties like width, height, top, or margin forces the browser to recalculate positions on every frame and can get janky. Stick to transform and opacity whenever you can. (If you haven’t met transforms yet, see the transitions article — the same performance rule applies there.)
Looping: animation-iteration-count
By default an animation plays exactly once and stops. To make it repeat, set animation-iteration-count:
.spinner {
animation-name: spin;
animation-duration: 1s;
animation-iteration-count: infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
infinite is the value you’ll use most — it’s how every loading spinner on the web turns endlessly. You can also pass a plain number (animation-iteration-count: 3) to repeat a fixed number of times, which is handy for an attention-grabbing shake that runs three times and then settles.
Timing and easing: animation-timing-function
Just like transitions, animations have an easing curve that controls the rhythm of the motion — whether it starts slow and speeds up, or moves at a constant pace:
.box {
animation-name: fade-in;
animation-duration: 1s;
animation-timing-function: ease-in-out;
}
The common values are linear (constant speed — the right choice for a spinner, which should turn evenly), ease (the default — gentle start and end), ease-in, ease-out, and ease-in-out. For looping motion that should feel mechanical and steady, reach for linear; for anything that enters or settles, an ease variant feels more natural.
Delays, direction, and fill: the finishing touches
A few more properties give you precise control over how an animation behaves.
animation-delay waits before starting — perfect for staggering several elements so they animate in sequence instead of all at once:
.card:nth-child(1) { animation-delay: 0s; }
.card:nth-child(2) { animation-delay: 0.1s; }
.card:nth-child(3) { animation-delay: 0.2s; }
animation-direction controls which way the timeline plays. normal is start-to-end; reverse plays it backward; and alternate plays forward, then backward, then forward again — which is exactly what makes a pulse breathe in and out smoothly instead of snapping back to the start each loop:
.pulse {
animation-name: grow;
animation-duration: 1s;
animation-iteration-count: infinite;
animation-direction: alternate;
}
@keyframes grow {
from { transform: scale(1); }
to { transform: scale(1.1); }
}
animation-fill-mode decides what the element looks like before the animation starts and after it ends. By default, an element snaps back to its un-animated styles once the animation finishes. Set animation-fill-mode: forwards and it keeps the final keyframe’s styles instead — essential when you animate something in and want it to stay visible rather than vanish at the end.
The animation shorthand
Writing six separate animation-* lines gets tedious fast. The animation shorthand packs them all into one declaration:
.box {
animation: fade-in 1s ease-in-out 0.2s infinite alternate forwards;
}
The order is: name, duration, timing-function, delay, iteration-count, direction, and fill-mode. You don’t have to include all of them — duration and name are the only essentials, and the rest fall back to their defaults if you leave them out. A realistic spinner is just:
.spinner {
animation: spin 1s linear infinite;
}
Duration comes before delay
The shorthand takes two time values: the first is always the duration, the second is the delay. So animation: spin 2s 1s means a 2-second animation that starts after a 1-second wait — not the other way around. If your animation runs at the wrong speed, this swapped pair is a common culprit. When in doubt, write animation-duration and animation-delay out separately until it’s working, then collapse to the shorthand.
A real example: a loading spinner
Let’s build the single most common CSS animation on the entire web — a spinner — from scratch:
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e0e0e0; /* light grey ring */
border-top-color: #00b8e6; /* one coloured segment */
border-radius: 50%; /* make it a circle */
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
<div class="spinner"></div>
The trick is elegant: a circle with a grey border, except one side is coloured. Spin the whole thing forever with linear timing, and your eye reads the coloured arc chasing its tail. Notice the keyframes only need a to — when you omit from, the browser uses the element’s current state as the starting point, so rotate(0deg) is implied. That’s the spinner you’ve seen a thousand times, in five lines of CSS.
A second example: a pulsing notification dot
Here’s another pattern you’ll reach for — a dot that gently pulses to draw the eye, like an unread-message indicator:
.notification-dot {
width: 12px;
height: 12px;
background: #ff5252;
border-radius: 50%;
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.6; }
}
Grouping 0%, 100% on one line is a tidy shortcut: it means “be like this at both the start and the end,” so the dot returns to its resting size every loop. At the midpoint it swells and fades slightly, then comes back — a soft, breathing pulse that’s noticeable without being annoying.
Respect users who prefer less motion
Not everyone enjoys movement on a page — for some people it causes genuine discomfort or dizziness. Browsers expose a setting for this, and you should honour it. The prefers-reduced-motion media query lets you dial animations down for users who’ve asked their system to minimize motion:
@media (prefers-reduced-motion: reduce) {
.spinner,
.pulse,
.notification-dot {
animation: none;
}
}
This is an accessibility requirement, not a nice-to-have
Honouring prefers-reduced-motion isn’t optional polish — for users with vestibular disorders, unwanted motion can trigger nausea and dizziness. Any time you add looping, sliding, zooming, or parallax motion, add a prefers-reduced-motion: reduce rule that turns it off or tones it down. It’s a small block of CSS that makes your site usable for people who’d otherwise have to leave it.
Pausing and resuming with animation-play-state
One last useful property: animation-play-state can pause a running animation and running it again. This is the clean way to stop a spinner on hover, or freeze a carousel while the pointer is over it:
.spinner:hover {
animation-play-state: paused;
}
The animation freezes exactly where it is and picks up from that same point when you move away — no jump, no restart. It pairs nicely with JavaScript too: toggle a class, and you can start and stop CSS animations without ever recreating them.
Wrapping up
CSS animations let you build motion that runs on its own — looping, multi-step, and totally JavaScript-free. Here’s what you’ve got under your belt:
@keyframes name { ... }defines the timeline usingfrom/toor percentage stops;animation-name+animation-durationapply it.- Use percentage keyframes for multi-step motion, and animate
transformandopacityfor smooth, GPU-friendly results. animation-iteration-count: infiniteloops forever;animation-timing-functionsets the easing (linearfor spinners).animation-delaystaggers,animation-direction: alternatemakes pulses breathe, andanimation-fill-mode: forwardskeeps the final state.- The
animationshorthand packs it all in — just remember duration comes before delay. - Always add a
prefers-reduced-motionrule, and useanimation-play-stateto pause and resume.
With transitions and animations both in your toolkit, you can make almost any interface feel alive and responsive without touching JavaScript. Next we’ll look at CSS shadows — the box and text shadows that give your designs depth and lift them off the page.