CSS Animations Tutorial: Keyframes, Transitions, and Timing

- CSS animations in one minute
- Transitions: animate a change of state
- Keyframe animations: define a full sequence
- Timing functions: why motion feels right or wrong
- Transition vs animation: which should you use?
- Performance: animate transform and opacity
- Accessibility: respect reduced motion
- A complete, copy-pasteable example
- Common mistakes to avoid
CSS animations in one minute
CSS gives you two built-in ways to move things: transitions and keyframe animations. A transition animates a property from its current value to a new one when something changes — a hover, a class toggle, a focus. A keyframe animation runs a named sequence you define ahead of time, with as many steps as you want, and it can loop, reverse, and run on its own without any user interaction.
Here is the whole idea in two snippets. First, a transition:
.button {
background: #2563eb;
transition: background 200ms ease;
}
.button:hover {
background: #1e40af;
}
And the same kind of motion as a keyframe animation:
@keyframes pulse {
from { transform: scale(1); }
to { transform: scale(1.05); }
}
.button {
animation: pulse 600ms ease-in-out infinite alternate;
}
That's the fork in the road. The rest of this tutorial explains when to reach for each, every property that controls them, the timing functions that make motion feel right, and how to keep animations smooth and accessible.
Transitions: animate a change of state
A transition needs two things: a starting value already on the element, and a new value applied by some state change. If both exist, the browser animates between them instead of snapping.
The shorthand takes up to four parts, in this order:
transition: <property> <duration> <timing-function> <delay>;
.card {
transform: translateY(0);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 250ms ease-out, box-shadow 250ms ease-out;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
}
Note that you can list several transitions separated by commas, each with its own duration and easing. That is almost always better than transition: all, which forces the browser to watch every animatable property and can accidentally animate things you didn't mean to.
The four transition properties
| Property | What it controls | Example |
|---|---|---|
transition-property |
Which properties animate | transform, opacity |
transition-duration |
How long it takes | 200ms, 0.3s |
transition-timing-function |
The acceleration curve | ease, cubic-bezier(...) |
transition-delay |
Wait before starting | 100ms |
Durations accept seconds (0.3s) or milliseconds (300ms); both are valid, and milliseconds tend to read more clearly for short UI motion. Anything under roughly 100ms feels instant, and most interface transitions land well between 150ms and 400ms. Longer than about 500ms starts to feel sluggish for a hover.
What you can and can't transition
Not every property animates. A value has to have a defined "in-between" for the browser to interpolate it. Numbers, lengths, colors, and transforms interpolate cleanly. Discrete values like display historically do not — which is why the classic trick is to animate opacity and visibility instead of display: none. Modern browsers now support transitioning to display: none using transition-behavior: allow-discrete, but check support before relying on it.
Keyframe animations: define a full sequence
When you need more than a single A-to-B change — a loop, multiple stops, motion that starts on its own — use @keyframes. You define the sequence once and attach it with the animation property.
@keyframes slide-in {
0% {
opacity: 0;
transform: translateX(-20px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.panel {
animation: slide-in 400ms ease-out both;
}
Keyframes use percentages from 0% to 100% (or the keywords from and to, which mean 0% and 100%). You can add as many stops as you like, and several selectors can share one rule:
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
The animation sub-properties
The animation shorthand bundles eight longhand properties. You rarely need all of them, but knowing each one is what separates copy-pasting from actually controlling motion.
| Property | Purpose | Common values |
|---|---|---|
animation-name |
The @keyframes to run |
your rule name |
animation-duration |
Length of one cycle | 600ms, 2s |
animation-timing-function |
Easing curve | ease-in-out, linear |
animation-delay |
Wait before first run | 200ms |
animation-iteration-count |
How many times | 1, 3, infinite |
animation-direction |
Play order | normal, alternate, reverse |
animation-fill-mode |
State before/after running | none, forwards, both |
animation-play-state |
Running or paused | running, paused |
A full shorthand reads left to right — the first time value is duration, the second is delay:
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
Fill mode is the one people miss
By default, an element snaps back to its unanimated styles the instant the animation ends. animation-fill-mode fixes that:
forwardskeeps the final keyframe's styles after it finishes.backwardsapplies the first keyframe's styles during any delay, before it starts.bothdoes both.
If an element flickers back to its original position when the animation stops, you almost certainly want forwards or both.
Timing functions: why motion feels right or wrong
The timing function is the difference between motion that feels natural and motion that feels robotic. It maps elapsed time to progress, controlling acceleration.
| Keyword | Feel | Good for |
|---|---|---|
linear |
Constant speed | Spinners, progress bars |
ease |
Slow-fast-slow (default) | General UI |
ease-in |
Starts slow | Elements leaving |
ease-out |
Ends slow | Elements entering |
ease-in-out |
Slow at both ends | Loops, emphasis |
For anything else, write your own curve with cubic-bezier(x1, y1, x2, y2). The four numbers are the control points; the X values must stay between 0 and 1, while Y can overshoot to create a springy, bouncing feel:
.modal {
transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
There is also steps(n), which jumps through the animation in discrete frames instead of interpolating smoothly — perfect for sprite-sheet animation or a typewriter effect:
@keyframes typing {
from { width: 0; }
to { width: 20ch; }
}
.caption {
width: 20ch;
overflow: hidden;
white-space: nowrap;
animation: typing 2s steps(20) forwards;
}
Transition vs animation: which should you use?
They overlap, but the choice is usually clear once you name what you need.
| Question | Transition | Keyframe animation |
|---|---|---|
| Triggered by a state change (hover, focus, class)? | Yes | Optional |
| Needs to run on its own, no interaction? | No | Yes |
| More than a start and end point? | No | Yes |
| Needs to loop or reverse automatically? | No | Yes |
| Simplest possible code? | Yes | — |
Rule of thumb: if you're animating the response to something the user did and it's a single A-to-B change, use a transition. If the motion is a multi-step sequence, loops, or plays automatically, use a keyframe animation.
Performance: animate transform and opacity
The single most important performance rule in CSS animation is this: animate transform and opacity, and avoid animating layout properties.
When you animate width, height, top, left, margin, or padding, the browser has to recalculate the position and size of elements — a step called layout (or reflow) — on every frame. That is expensive and causes jank. transform and opacity, by contrast, can be handled by the compositor, often on the GPU, without touching layout at all.
So instead of animating position with left, animate it with transform: translateX(). Instead of animating size with width, use transform: scale().
/* Janky: triggers layout every frame */
.box { transition: left 300ms ease; }
/* Smooth: composited, no layout */
.box { transition: transform 300ms ease; }
The will-change property can hint to the browser to prepare an element for animation, but use it sparingly — applying it to too many elements wastes memory and can hurt performance:
.menu {
will-change: transform;
}
Add it only to elements you're about to animate, and consider removing it after. Because animations often lean on custom properties and gradients for polish, it's worth pairing this with a solid grasp of CSS custom properties and CSS gradients so your motion and your visuals stay in sync.
Accessibility: respect reduced motion
Animation can cause real harm. For people with vestibular disorders, large or spinning motion can trigger dizziness and nausea. Operating systems expose a "reduce motion" setting, and CSS lets you honor it with a media query:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
That blanket reset is a common starting point, but the better practice is to design motion that degrades gracefully — keep essential feedback like a color change while removing large movement:
.card {
transition: transform 250ms ease;
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
The goal isn't to strip all feedback — it's to remove the motion that can make someone physically unwell. This kind of viewport- and preference-aware thinking is core to responsive web design, where the "right" experience depends on the user and their device, not a single fixed design.
A complete, copy-pasteable example
Here is a card that fades and slides in on load, lifts on hover, and respects reduced-motion preferences — every technique from this guide in one place.
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.feature-card {
animation: fade-up 500ms ease-out both;
transition: transform 200ms ease, box-shadow 200ms ease;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.feature-card:hover {
transform: translateY(-6px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
}
@media (prefers-reduced-motion: reduce) {
.feature-card {
animation: none;
transition: none;
}
}
Common mistakes to avoid
- Animating
all. Naming specific properties is faster and avoids surprise motion. - Forgetting a start value. A transition needs the property already set on the base element, not just on
:hover. - Animating layout properties. Reach for
transformandopacityfirst. - Dropping
fill-mode. If your animation snaps back at the end, addforwardsorboth. - Ignoring reduced motion. One media query covers the most important accessibility case.
- Over-animating. Motion should guide attention, not compete for it. When everything moves, nothing stands out.
Get comfortable with transitions first — they cover the majority of real-world interface motion — then layer in keyframe animations when you need loops, sequences, or self-starting effects. Keep every animation on transform and opacity, honor prefers-reduced-motion, and your interfaces will feel fast and polished without a single line of JavaScript.