Keyframes describe a sequence
A transition needs something to change. An animation runs by itself: you describe the states, and the browser plays through them.
@keyframes rise {
from {
opacity: 0;
translate: 0 12px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.card {
animation: rise 400ms ease-out both;
}
from and to are 0% and 100%. Any percentage in between is allowed, and several can share one block.
The shorthand, unpacked
animation: rise 400ms ease-out 100ms 1 normal both running;
/* name duration easing delay count direction fill-mode play-state */
/* which is the same as */
animation-name: rise;
animation-duration: 400ms;
animation-timing-function: ease-out;
animation-delay: 100ms;
animation-iteration-count: 1; /* or infinite */
animation-direction: normal; /* reverse, alternate, alternate-reverse */
animation-fill-mode: both;
animation-play-state: running; /* paused */
fill-mode is the one that confuses people
An entrance animation almost always wants both: the element is already at the start state during the delay (so it does not flash at full opacity first) and stays at the end state afterwards.
| Value | Before the delay | After it finishes |
|---|---|---|
none | The element's own style | The element's own style — it snaps back |
forwards | Its own style | Holds the last keyframe |
backwards | Holds the first keyframe | Its own style |
both | Holds the first | Holds the last |
A staggered entrance
.item {
animation: rise 420ms cubic-bezier(.2, .7, .1, 1) both;
animation-delay: calc(var(--i, 0) * 60ms);
}
<li style="--i:0">One</li>
<li style="--i:1">Two</li>
<li style="--i:2">Three</li>
Six items at 60ms is 360ms — fine. Twenty items at 100ms is two seconds of the reader waiting for a list. Cap the delay: calc(min(var(--i), 8) * 50ms).
Looping animations
A spinner is the one place linear is right. Anything looping forever should be small, quiet and far from the text — a looping animation beside something you are reading is genuinely hard to ignore.
@keyframes spin {
to { rotate: 360deg; }
}
.spinner {
animation: spin 900ms linear infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.dot {
animation: pulse 1.6s ease-in-out infinite;
}
Starting when the element appears
An animation on an element below the fold has already finished by the time it is scrolled into view. Two ways to fix it:
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("in");
io.unobserve(e.target);
}
}, { rootMargin: "-10% 0px" });
document.querySelectorAll("[data-reveal]").forEach((el) => io.observe(el));
@keyframes rise {
from { opacity: 0; translate: 0 14px; }
}
.reveal {
animation: rise linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
animation-timeline ties the animation to scroll position instead of to time — no script, and it runs off the main thread. Check support before relying on it; the observer version works everywhere.
Scroll-driven progress
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress {
transform-origin: 0 50%;
animation: grow linear both;
animation-timeline: scroll(root block);
}
Reduced motion, again
Setting animation: none alone can leave an element stuck at opacity: 0 if that was its resting state. Always check that the reduced-motion path ends with the content visible.
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
opacity: 1;
translate: none;
}
}
Controlling it from script
el.animate() is the same engine as CSS animations, driven from JavaScript, and finished is a promise — which makes sequencing far easier than listening for animationend.
const a = el.animate(
[{ opacity: 0, transform: "translateY(12px)" }, { opacity: 1, transform: "none" }],
{ duration: 400, easing: "cubic-bezier(.2,.7,.1,1)", fill: "both" }
);
await a.finished;
a.pause();
a.currentTime = 200;
el.getAnimations().forEach((x) => x.cancel());