What Animations Do That Transitions Cannot
A transition needs something to change: a hover, a focus, a class added by a script. An animation does not. It runs on its own as soon as the element exists, which is the first of three real differences.
The second is that an animation can have as many stages as you like. A transition goes from one value to another and stops; an animation can move through five, ten or twenty points along the way, changing direction and easing at each. The third is repetition — an animation can loop a fixed number of times or forever, and it can play forwards, backwards or alternate between the two.
That gives you a clean rule for choosing between them. If the movement is a response to something a person did, use a transition; it will be shorter to write and easier to reason about. If the movement is continuous or self-starting — a loading spinner, a skeleton placeholder shimmering while data arrives, a hero heading sliding in when the page opens, an attention pulse on a notification dot — use an animation.
Building one takes two steps. First, define the sequence with @keyframes and give it a name. Second, attach that name to an element with the animation property. The keyframes on their own do nothing at all until something references them, which is the most common reason a first attempt shows no movement.
/* Step 1: describe the sequence and name it */
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Step 2: attach it. Without this, the keyframes do nothing. */
.hero h1 {
animation: fade-up 0.6s ease-out;
} - Transitions react to a change; animations start by themselves
- Transitions have two states; animations can have any number
- Only animations can repeat, reverse or alternate
@keyframesdefines the sequence; theanimationproperty runs it- Keyframe names are global to the stylesheet, so keep them descriptive
Writing @keyframes
Inside a @keyframes block, each stage is a position in the animation's timeline written as a percentage, with from and to available as friendlier names for 0% and 100%. At each stage you list the property values the element should have at that moment, and the browser calculates everything in between.
You do not have to mention every property at every stage. Anything you leave out simply keeps interpolating between the stages that do mention it, which keeps the block readable. Stages that share the same values can also be grouped with a comma — 0%, 100% { transform: scale(1); } is how you describe something that returns to where it started.
The stages do not have to be evenly spaced, and using that deliberately is what separates a mechanical animation from a convincing one. A bounce that spends 60% of its time falling and 40% recovering feels like gravity; the same movement at even intervals feels like a machine. Percentages are your control over that rhythm, on top of whatever timing function you apply.
/* from / to for a simple two-stage sequence */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Percentages for anything longer */
@keyframes bounce {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-24px); }
60% { transform: translateY(-12px); }
80% { transform: translateY(-4px); }
}
/* Only list what changes at each stage */
@keyframes attention {
0% { transform: scale(1); background: #d1039e; }
50% { transform: scale(1.08); } /* colour keeps interpolating */
100% { transform: scale(1); background: #d1039e; }
}
/* A continuous rotation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
} - Keyframe names live in one global namespace for the whole document. Two
@keyframesblocks with the same name mean the later one silently replaces the earlier one, so prefix names that belong to a component —card-fade-uprather thanfade.
The animation Properties, and the fill-mode Trap
The animation shorthand can set eight things. Four are familiar from transitions: the name, the duration, the timing function and the delay — and as with transitions, when you write two time values the first is the duration and the second is the delay. animation-iteration-count takes a number or infinite. animation-direction chooses normal, reverse, alternate (forwards then backwards, repeatedly) or alternate-reverse. animation-play-state can pause a running animation, which is how you stop a marquee on hover.
The eighth is animation-fill-mode, and it is the one that produces the classic "why did my element jump back?" bug. By default an animation only controls the element while it is running. The instant it finishes, the element reverts to whatever its normal CSS says. So an entrance animation that ends at opacity: 1 on an element whose base rule says opacity: 0 will fade in beautifully and then vanish.
animation-fill-mode: forwards tells the element to keep the values from the final keyframe after the animation ends. backwards applies the first keyframe's values during the delay, before the animation starts — without it, an element with a one-second delay is visible in its base state for that whole second and then suddenly snaps to the start of the animation. both does both, and is the sensible default for entrance animations.
Staggering is one of the easiest wins in the whole topic: give a list of cards the same animation with an increasing delay and they arrive one after another instead of all at once. Three or four items with 80 milliseconds between them is enough to make a page feel considered.
/* name | duration | timing | delay | count | direction | fill-mode */
.hero h1 {
animation: fade-up 0.6s ease-out 0.1s 1 normal both;
}
/* The jump-back bug */
.item { opacity: 0; animation: fade-in 0.5s; } /* fades in, then vanishes */
.item { opacity: 0; animation: fade-in 0.5s forwards; } /* stays visible */
/* An infinite spinner */
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e3e3ea;
border-top-color: #d1039e;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Alternate bounces back and forth without a jump */
.pulse { animation: attention 1.2s ease-in-out infinite alternate; }
/* Pause on hover */
.ticker:hover { animation-play-state: paused; }
/* Stagger a list */
.card:nth-child(1) { animation-delay: 0s; }
.card:nth-child(2) { animation-delay: 0.08s; }
.card:nth-child(3) { animation-delay: 0.16s; }
.card:nth-child(4) { animation-delay: 0.24s; } animation-iteration-count— a number, orinfiniteanimation-direction: alternate— plays forwards then backwards, avoiding a jump on each loopanimation-fill-mode: forwards— hold the last keyframe after finishinganimation-fill-mode: backwards— apply the first keyframe during the delayanimation-play-state: paused— freeze without removing the animation- Increasing delays across sibling elements produce a stagger
Using Animation Well, and Knowing When Not To
Animation earns its place when it communicates something. A spinner says work is happening. A skeleton shimmer says content is on its way and roughly what shape it will be. A gentle entrance tells the eye where to look first on a page it has never seen. Each of those replaces a moment of uncertainty with information.
It stops earning its place when it is decoration competing with content. An element that pulses forever beside a paragraph makes that paragraph harder to read, because peripheral movement pulls attention involuntarily — you cannot choose to ignore it. Infinite animations also keep the device redrawing the screen, which costs battery on a phone. If something must loop forever, keep it small, keep it slow, and keep it away from text people are trying to read.
Two accessibility rules are not negotiable. Honour prefers-reduced-motion, because for people with vestibular conditions screen movement causes genuine nausea. And never flash content rapidly: content that flashes more than three times in a second can trigger seizures in people with photosensitive epilepsy, which is why the accessibility guidelines set that limit explicitly.
For the same performance reasons as transitions, build animations out of transform and opacity wherever possible. A spinner made by rotating a bordered circle stays smooth on a low-end phone; the same effect built by animating width and height does not.
/* Skeleton shimmer while content loads */
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton {
height: 14px;
border-radius: 4px;
background: linear-gradient(90deg, #eeeef2 25%, #f8f8fb 50%, #eeeef2 75%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
/* Always include this block */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
} HTML
<h3>Loading states</h3>
<div class="row">
<div class="spinner"></div>
<div class="skeleton-block">
<div class="skeleton" style="width: 70%"></div>
<div class="skeleton" style="width: 90%"></div>
<div class="skeleton" style="width: 50%"></div>
</div>
</div>
<h3>Staggered entrance — reload the preview to replay</h3>
<div class="cards">
<div class="card">One</div>
<div class="card">Two</div>
<div class="card">Three</div>
<div class="card">Four</div>
</div>
<h3>Attention pulse (hover to pause)</h3>
<span class="dot"></span> CSS
body {
font-family: system-ui, Arial, sans-serif;
padding: 24px;
background: #f4f4f7;
}
h3 { font-size: 15px; color: #555555; margin: 24px 0 8px; }
.row { display: flex; align-items: center; gap: 24px; }
/* --- spinner --- */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
width: 36px;
height: 36px;
border: 4px solid #e3e3ea;
border-top-color: #d1039e;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* --- skeleton --- */
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton-block { flex: 1; display: grid; gap: 8px; max-width: 320px; }
.skeleton {
height: 14px;
border-radius: 4px;
background: linear-gradient(90deg, #eeeef2 25%, #f8f8fb 50%, #eeeef2 75%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
/* --- staggered entrance --- */
@keyframes fade-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.cards { display: flex; gap: 12px; flex-wrap: wrap; }
.card {
background: #ffffff;
border: 1px solid #e3e3ea;
border-radius: 10px;
padding: 20px 28px;
opacity: 0; /* the base state */
animation: fade-up 0.5s ease-out both; /* 'both' keeps the last frame */
}
.card:nth-child(1) { animation-delay: 0s; }
.card:nth-child(2) { animation-delay: 0.08s; }
.card:nth-child(3) { animation-delay: 0.16s; }
.card:nth-child(4) { animation-delay: 0.24s; }
/* --- pulse --- */
@keyframes pulse {
from { transform: scale(1); opacity: 1; }
to { transform: scale(1.6); opacity: 0.4; }
}
.dot {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
background: #d1039e;
animation: pulse 0.9s ease-in-out infinite alternate;
}
.dot:hover { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
} - An entrance animation that starts from
opacity: 0hides its content until the animation runs. If the CSS fails to load or a script error stops the class being applied, that content is invisible rather than merely unanimated. For anything essential, prefer animating from a visible state, or add the starting class with JavaScript so that a page without it still shows everything.
