Keyframe Animations
🎬 Chapter 10 — Keyframe Animations
Transitions animate between two states — the element either has a property or it doesn't, and the browser interpolates between them. Keyframe animations go further: they define an entire timeline of states — at 0%, at 40%, at 100% — and the browser plays through them automatically. They can loop, reverse, pause, and run independently of any user interaction.
1 — Transitions vs. Animations
- Triggered by a state change (hover, class, focus)
- Two endpoints only — from and to
- Play once per trigger
- Reverse automatically on exit
- Simpler to write
- Self-starting — can run on page load
- Multiple intermediate keyframes
- Loop, repeat, alternate, pause
- Full control over timing per-keyframe
- Reusable — define once, apply anywhere
2 — @keyframes Syntax
A @keyframes rule defines a named animation timeline. Each keyframe (percentage stop) declares the CSS state at that point in time. The browser interpolates between adjacent keyframes.
/* Simplest form — from and to (= 0% and 100%) */
@keyframes slide-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Multiple stops with per-keyframe timing */
@keyframes bounce {
0% { transform: translateY(0); animation-timing-function: ease-in; }
40% { transform: translateY(-40px); animation-timing-function: ease-out; }
60% { transform: translateY(-28px); }
80% { transform: translateY(-40px); animation-timing-function: ease-out; }
100% { transform: translateY(0); }
}
/* Comma-list: two stops share the same declarations */
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.25); opacity: 0.75; }
}
/* Only the properties you declare are animated — anything
not mentioned in the keyframes inherits its normal value. */
slide-in, fade-out, spin — not animation1. The name is reused on every element that plays it.3 — Applying Animations
The animation shorthand packs all eight sub-properties into one line. The only order rule: the first time value is always animation-duration and the second (if present) is animation-delay.
/* Full shorthand — name dur timing delay count dir fill play-state */
.box {
animation: bounce 0.8s ease 0s infinite normal none;
}
/* Equivalent individual properties */
.box {
animation-name: bounce;
animation-duration: 0.8s;
animation-timing-function: ease;
animation-delay: 0s;
animation-iteration-count: infinite;
animation-direction: normal;
animation-fill-mode: none;
animation-play-state: running;
}
4 — Timing Functions and steps()
Each keyframe-to-keyframe segment can have its own animation-timing-function. The global one (set on the element) applies to every segment by default. Override it inside a @keyframes stop to control individual segments — the bounce demo above uses this for the asymmetric up/down feel.
/* Named easings */
animation-timing-function: linear;
animation-timing-function: ease; /* default */
animation-timing-function: ease-in;
animation-timing-function: ease-out;
animation-timing-function: ease-in-out;
/* Custom cubic bezier */
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* spring */
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); /* Material ease */
/* steps() — discrete jumps (sprite animation, typewriter) */
animation-timing-function: steps(4); /* 4 equal jumps */
animation-timing-function: steps(4, end); /* default: jump at end of each step */
animation-timing-function: steps(4, start); /* jump at start */
/* Typewriter effect with steps() */
@keyframes typewriter {
from { width: 0; }
to { width: 20ch; } /* ch = width of "0" character */
}
.typewriter {
white-space: nowrap;
overflow: hidden;
border-right: 2px solid;
animation: typewriter 3s steps(20, end) 1 forwards,
blink-cursor 0.75s step-end infinite;
}
@keyframes blink-cursor {
0%,100% { border-color: currentColor; }
50% { border-color: transparent; }
}
step-start is shorthand for steps(1, start) and step-end is shorthand for steps(1, end) — useful for the blinking cursor pattern above.5 — animation-direction
/* Infinite ping-pong — alternate is cleaner than two @keyframes */
.loader-dot {
animation: slide-right 0.6s ease-in-out infinite alternate;
}
/* Breathing / glow effect — scale up, scale back */
.glow {
animation: pulse 2s ease-in-out infinite alternate;
}
6 — animation-fill-mode
fill-mode controls what styles apply to the element outside the animation's active period — before a delay kicks in, and after the last iteration ends.
/* Without fill-mode: forwards — element jumps back to opacity:1 after fade-in */
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ✗ Without forwards: element visible (opacity:1) during delay, then
jumps to opacity:0 at start, then fades in, then jumps back. Ugly. */
.bad { animation: fade-in 0.5s ease 1s 1 none; }
/* ✓ With both: invisible during delay, fades in, stays visible after */
.good { animation: fade-in 0.5s ease 1s 1 both; }
/* The pattern: entrance animation + delay → always use fill-mode: both */
7 — animation-play-state
animation-play-state: paused freezes the animation at its current point. The most common use: pause infinite animations on hover for better user experience.
/* Pause on hover — user can inspect or stop a spinning element */
.spinner { animation: spin 1s linear infinite; }
.spinner:hover { animation-play-state: paused; }
/* Toggle play/pause with a class via JavaScript */
.animated { animation: pulse 1.5s infinite; }
.animated.paused { animation-play-state: paused; }
/* JS: toggle the .paused class */
// btn.addEventListener('click', () => el.classList.toggle('paused'));
/* Pause everything during loading, release when ready */
.page-loading * { animation-play-state: paused; }
8 — Multiple Animations
Separate multiple animations with commas. Each runs independently — they don't interact unless they target the same property, in which case the last one listed wins.
/* Spin and pulse simultaneously */
.loader {
animation:
spin 1s linear infinite,
pulse 2s ease-in-out infinite;
}
/* Slide in, then blink cursor after */
.typewriter {
animation:
typewriter 3s steps(30, end) 1 forwards,
blink-cursor 0.75s step-end 3s infinite;
/* cursor blinks only after typing finishes (3s delay) */
}
/* Float + gentle sway — two animations on different properties */
@keyframes float { 0%,100% {transform:translateY(0)} 50% {transform:translateY(-12px)} }
@keyframes shadow { 0%,100% {opacity:0.6;transform:scale(1)} 50% {opacity:0.3;transform:scale(0.85)} }
.floating-icon { animation: float 3s ease-in-out infinite; }
.floating-shadow { animation: shadow 3s ease-in-out infinite; }
9 — Staggered Animations
Apply the same animation to multiple elements but give each one an increasing animation-delay. The result is a cascade where items appear one after another, creating a sense of choreography and flow.
/* Pattern 1: nth-child selectors (small, fixed lists) */
.item { animation: fade-in 0.4s ease both; }
.item:nth-child(2) { animation-delay: 0.1s; }
.item:nth-child(3) { animation-delay: 0.2s; }
.item:nth-child(4) { animation-delay: 0.3s; }
/* Pattern 2: CSS custom property set by JS (scales to any length) */
.item {
animation: slide-up 0.45s ease both;
animation-delay: calc(var(--i, 0) * 80ms);
}
/* JS: items.forEach((el, i) => el.style.setProperty('--i', i)); */
/* Pattern 3: Negative delay — items already mid-animation on load */
.wave-item {
animation: wave 1.2s ease-in-out infinite alternate;
animation-delay: calc(var(--i, 0) * -200ms);
/* Negative delay starts the animation already partway through — */
/* creates a wave that looks like it's been running forever. */
}
10 — Common Animation Patterns
/* Fade in from below */
@keyframes fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
/* Shake / error feedback */
@keyframes shake {
0%,100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-5px); }
80% { transform: translateX(5px); }
}
.input.error { animation: shake 0.5s ease 1; }
/* Skeleton loading shimmer */
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
#1a1d27 25%,
#2a2d3a 50%,
#1a1d27 75%
);
background-size: 400% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* Attention-getter: wiggle a notification dot */
@keyframes ping {
from { transform: scale(1); opacity: 1; }
to { transform: scale(2.5); opacity: 0; }
}
/* Place .ping behind the dot using position:absolute — it expands and fades */
.notification-ping { animation: ping 1.5s ease-out infinite; }
11 — prefers-reduced-motion
/* Nuclear option — disable all animations site-wide */
@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;
}
}
/* Better — replace large motion with a subtle alternative */
.hero-animation {
animation: slide-in-left 0.8s ease both;
}
@media (prefers-reduced-motion: reduce) {
.hero-animation {
animation: fade-in 0.3s ease both;
/* Keep opacity fade — it's the big translate that's disorienting */
}
}
/* No-preference (motion is OK) — opt-in to a more elaborate animation */
@media (prefers-reduced-motion: no-preference) {
.card { animation: bounce-in 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
}
12 — Quick Reference
| Property | Values | Notes |
|---|---|---|
animation-name | Keyframe name or none | Matches a @keyframes rule name |
animation-duration | Time in s or ms | Required — defaults to 0s (instant) |
animation-timing-function | ease, linear, steps(n), cubic-bezier() | Applies to each keyframe segment |
animation-delay | Time; negative = start partway through | Negative values useful for stagger waves |
animation-iteration-count | Number or infinite | Can be a decimal: 1.5 = 1½ plays |
animation-direction | normal, reverse, alternate, alternate-reverse | alternate = natural ping-pong loop |
animation-fill-mode | none, forwards, backwards, both | Use both for delayed entrance animations |
animation-play-state | running, paused | Toggle with a class or :hover |
| Pattern | Key properties |
|---|---|
| Entrance (one-shot) | iteration-count: 1; fill-mode: both |
| Infinite loop | iteration-count: infinite; fill-mode: none |
| Ping-pong loop | iteration-count: infinite; direction: alternate |
| Stagger cascade | Increasing animation-delay per element |
| Pause on hover | .el:hover { animation-play-state: paused } |
| Reduced motion | @media (prefers-reduced-motion: reduce) |
✏️ Exercises
All exercises should wrap their animations in a prefers-reduced-motion: reduce query that either removes or simplifies the motion.
<div> with a shimmer animation — a gradient that sweeps left to right giving the illusion of light passing over the surface. The gradient should be 400% wide so it travels fully across. Add a stagger so each bar's shimmer starts slightly offset from the previous.background: linear-gradient(90deg, #eee 25%, #ddd 50%, #eee 75%) with background-size: 400% 100% and animate background-position from -200% 0 to 200% 0. Stagger via animation-delay on each nth-child.@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton-card {
padding: 1.5rem;
border-radius:8px;
background: #1a1d27;
display: flex;
flex-direction:column;
gap: 10px;
}
.skeleton-line {
height: 14px;
border-radius: 4px;
background: linear-gradient(90deg,
#1e2235 25%, #2a2d3a 50%, #1e2235 75%);
background-size: 400% 100%;
animation: shimmer 1.8s ease-in-out infinite;
}
.skeleton-line.title { width: 70%; height: 20px; }
.skeleton-line:nth-child(2) { animation-delay: 0.15s; }
.skeleton-line:nth-child(3) { width: 55%; animation-delay: 0.3s; }
@media (prefers-reduced-motion: reduce) {
.skeleton-line { animation: none; opacity: 0.5; }
}
steps(). After typing completes, a blinking cursor (the right border of the element) should blink indefinitely. The cursor should start blinking only after the typing animation finishes — use animation-delay on the cursor animation equal to the typing duration. Wrap everything in a prefers-reduced-motion override that shows the full text immediately.overflow: hidden; white-space: nowrap on the element, animate width from 0 to 13ch using steps(13, end). The cursor uses a separate @keyframes toggling border-right-color between the text colour and transparent.@keyframes typing {
from { width: 0; }
to { width: 13ch; }
}
@keyframes blink {
0%,100% { border-color: currentColor; }
50% { border-color: transparent; }
}
.typewriter {
display: inline-block;
overflow: hidden;
white-space: nowrap;
border-right: 2px solid;
animation:
typing 2s steps(13, end) 0s 1 forwards,
blink 0.75s step-end 2s infinite;
}
@media (prefers-reduced-motion: reduce) {
.typewriter {
animation: none;
width: auto;
border-right: none;
}
}
.ringing is added via JavaScript. The ring: rotate from -15deg to 15deg rapidly (4–6 oscillations), using a single @keyframes with percentage stops. Set transform-origin: top center so it swings from its top. After the animation finishes, the class should be removed — use JavaScript's animationend event. The animation should not repeat.transform-origin: top center on the icon. JS: el.addEventListener('animationend', () => el.classList.remove('ringing'))./* CSS */
@keyframes ring {
0% { transform: rotate(0deg); }
15% { transform: rotate(15deg); }
30% { transform: rotate(-15deg); }
45% { transform: rotate(12deg); }
60% { transform: rotate(-10deg); }
75% { transform: rotate(6deg); }
90% { transform: rotate(-4deg); }
100% { transform: rotate(0deg); }
}
.bell { transform-origin: top center; }
.bell.ringing { animation: ring 0.7s ease-out 1; }
@media (prefers-reduced-motion: reduce) {
.bell.ringing { animation: none; }
}
// JavaScript
const bell = document.querySelector('.bell');
// Ring when triggered
function ringBell() {
bell.classList.remove('ringing');
// Force reflow so re-adding the class restarts the animation
void bell.offsetWidth;
bell.classList.add('ringing');
}
// Auto-clean class when done
bell.addEventListener('animationend', () => {
bell.classList.remove('ringing');
});
.loaded class is added to the parent container. Six .card items should each fade up (opacity 0→1 + translateY 24px→0) with a cascade delay: 0, 80ms, 160ms, 240ms, 320ms, 400ms. Use CSS custom properties and calc() for the delays — set --i on each card via JavaScript. Each card starts invisible and only becomes visible when the animation begins (use fill-mode: both). Include a reduced-motion fallback that shows all cards immediately without animation.animation: fade-up 0.45s ease calc(var(--i, 0) * 80ms) both. JS: cards.forEach((c, i) => c.style.setProperty('--i', i)). The .loaded class triggers the animation only when added to the parent./* CSS */
@keyframes fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.card-grid .card {
opacity: 0; /* hidden before animation */
}
.card-grid.loaded .card {
animation: fade-up 0.45s ease
calc(var(--i, 0) * 80ms)
1 normal both;
}
@media (prefers-reduced-motion: reduce) {
.card-grid .card { opacity: 1; }
.card-grid.loaded .card { animation: none; }
}
// JavaScript
const grid = document.querySelector('.card-grid');
const cards = grid.querySelectorAll('.card');
// Stamp each card with its index
cards.forEach((card, i) => card.style.setProperty('--i', i));
// Trigger — e.g. after data loads or IntersectionObserver fires
grid.classList.add('loaded');