An animation is a transition that refused to wait for permission. You do not hover, you do not click, you load the page and the thing is already moving. That single difference is the source of most confusion in CSS motion, because half the time the effect you are chasing wanted a transition and you built an animation, or the reverse.
This page is the map for everything else on the site. Text effects, buttons, arrows, countdowns, blobs, checkmarks: each one is this exact mechanism pointed at a different property. Learn the mechanism once and the rest is choosing what to move and how fast.
What a CSS animation actually is
Two pieces, and neither does anything alone. A @keyframes rule describes the frames. An animation property on the element plays them. The keyframes block is just a named recipe sitting in your stylesheet until an element references it by name.
@keyframes pulse {
from { opacity: 1; }
to { opacity: 0.4; }
}
.dot {
animation-name: pulse;
animation-duration: 1s;
animation-iteration-count: infinite;
animation-direction: alternate;
}
from is 0%, to is 100%. When you need stops in between, use percentages instead, and you can list several percentages that share one rule.
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-20px); }
}
Any keyframe property you leave out falls back to the element’s own value. If .dot normally has opacity: 1 and your from block never mentions opacity, the animation borrows that 1. That fallback is why half-written keyframes still kind of work, then surprise you two commits later.
The animation properties, in the order that bites you
- animation-name: the
@keyframesto play. - animation-duration: how long one cycle runs. Defaults to
0s, which means nothing happens. This is the number one reason a “broken” animation is not broken, it is instant. - animation-timing-function: the speed curve. It gets its own section below because it earns it.
- animation-delay: wait before starting. Accepts negative values, which start the animation partway in.
- animation-iteration-count: a number, or
infinite. - animation-direction:
normal,reverse,alternate,alternate-reverse. - animation-fill-mode: what applies before the first frame and after the last. Default
nonesnaps the element back to its base style the instant the animation ends. - animation-play-state:
runningorpaused.
The shorthand packs all of that onto one line.
.dot {
animation: pulse 1s ease-in-out 0.2s infinite alternate both;
}
The order that trips people: the first time value is duration, the second is delay. Swap them and your 200ms delay becomes a 200ms animation that finishes before your eyes catch it.
animation-timing-function is where it lives or dies
The default is ease, which is cubic-bezier(0.25, 0.1, 0.25, 1). It starts a little slow, speeds up, slows down. Fine for nothing in particular, wrong for most specific things. Pick the curve on purpose:
- ease-out for things arriving. They rush in and settle. This is what feels native.
- ease-in for things leaving. They drift, then snap away.
- linear for anything that should not have a personality: spinners, marquees, progress that maps to real time.
- steps() for anything that should tick instead of glide: sprite sheets, typewriters, a countdown that lands on whole numbers.
.card {
animation: pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes pop {
from { transform: scale(0.8); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
That second control point at 1.56 pushes the curve above 1, so the card overshoots past its final size and settles back. That is a spring, without a JavaScript spring library.
CSS animation vs transition
A transition animates a change between two states, and it needs something to change: a class toggle, a :hover, a :focus. No trigger, no motion. An animation plays on its own and can loop forever. If you catch yourself writing a keyframe block with only a from and a to for an element that already has a hover state, you wanted a transition.
.button {
background: #2563eb;
transition: background 150ms ease-out, transform 150ms ease-out;
}
.button:hover {
background: #1d4ed8;
transform: translateY(-2px);
}
Two gotchas here. The transition lives on the base rule, not the :hover rule, so it applies in both directions, going in and coming back. And transition-duration defaults to 0s, the same trap as animation, so an unset duration means an instant jump with no easing at all.
One newer trick worth knowing: you used to be unable to transition an element in from display: none. Now transition-behavior: allow-discrete plus a @starting-style rule lets you animate entrances for popovers and dialogs without JavaScript. Support landed across the major engines through 2024, so check your targets before you make it the only path.
Animate transform and opacity, almost nothing else
The browser paints in stages: layout (where things go), paint (what they look like), composite (stacking the layers). transform and opacity skip straight to composite. They do not shove any other element around and they do not repaint, so the GPU handles them and you keep your whole frame budget.
Animating width, height, top, left, or margin triggers layout on every single frame. At 60fps you get 16.7ms per frame to do everything, and a reflow of a busy page eats that fast. The animation stutters, and it stutters worse on the cheap phone your user actually owns.
/* janky: moves via layout */
@keyframes slide-bad {
from { left: 0; }
to { left: 200px; }
}
/* smooth: moves via the compositor */
@keyframes slide-good {
from { transform: translateX(0); }
to { transform: translateX(200px); }
}
will-change: transform can promote an element to its own layer ahead of time, which helps a heavy animation start cleanly. It also costs memory, so do not paste it onto everything. Add it right before the motion, or apply it on :hover for an element you know is about to move, and let the browser reclaim it after.
Looping, direction, and fill-mode, the three that cause bug reports
animation-fill-mode: forwards is the fix for the classic “my animation runs, then everything snaps back” complaint. Default fill-mode is none, so the moment the last keyframe finishes, the element returns to its base style. forwards tells it to hold the final frame.
.toast {
animation: rise 300ms ease-out forwards;
}
@keyframes rise {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
alternate is the quiet win next to it. Instead of writing a keyframe that goes out and comes home, animate one direction and set animation-direction: alternate so every odd cycle plays in reverse. Half the keyframes, same result.
A negative animation-delay starts the animation as if it had already been running. Give five dots the same infinite pulse, then stagger them with delays of 0s, -0.2s, -0.4s, and so on, and they ripple instead of blinking in unison. Positive delay makes them wait. Negative delay makes them already busy.
Respect prefers-reduced-motion
Some users get motion sickness from parallax and big sweeping transforms. The operating system exposes that preference and CSS can read it. This is not optional politeness. It is the difference between a site someone can use and one that makes them close the tab holding their stomach.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
The blunt version above kills nearly all motion. A gentler approach is to design the reduced state per component: keep an opacity fade, drop the translate and scale. Fades rarely trigger motion sickness. Movement does.
A real one: the live status dot
Putting it together. A small dot that pulses to say something is live, respects reduced motion, and animates only opacity and transform.
<span class="live-dot" aria-label="Live"></span>
.live-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: #22c55e;
animation: live 1.4s ease-in-out infinite;
}
@keyframes live {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.4); opacity: 0.6; }
}
@media (prefers-reduced-motion: reduce) {
.live-dot { animation: none; }
}
Ten lines of keyframes, no library, no JavaScript, and it degrades to a plain green dot for anyone who asked for less motion. That is the whole job.
When CSS animation is not enough
CSS handles anything you can describe ahead of time. The moment the motion depends on live input (a drag position, a scroll offset you compute yourself, physics that reacts), you reach for the Web Animations API or a library. Element.animate() takes the same keyframes you already write, as a JavaScript array, and hands back a control object you can pause, reverse, or await.
const el = document.querySelector('.dot');
el.animate(
[{ opacity: 1 }, { opacity: 0.4 }],
{ duration: 1000, iterations: Infinity, direction: 'alternate' }
);
You also get real events from plain CSS animations without any of this. animationend and animationiteration fire on the element, so you can chain a second animation or strip a class when the first finishes. Reach for JavaScript when you need to react, not just to play.
Everything else on this site is a variation on what you just read. Pick a property, pick a curve, decide whether it loops. The specific effects are linked below.