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

Transitions
  • 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
Keyframe Animations
  • 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.

🎨 CSS — @keyframes syntax
/* 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. */
Name your animations with a verb or description of what they do: 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.

🎨 CSS — animation shorthand
/* 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; }
Animation Showcase
🎾

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.

🎨 CSS — timing functions in animations
/* 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

normalPlays 0%→100% each iteration. Default.
reversePlays 100%→0% each iteration. Timing functions also reverse.
alternateOdd iterations forward (0→100), even iterations backward (100→0). Creates a natural back-and-forth.
alternate-reverseLike alternate but starts backward. First iteration is 100→0.
🎨 CSS — direction examples
/* 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.

noneElement returns to its own CSS styles both before (delay) and after. Default.
forwardsAfter the last keyframe, the element keeps those styles. Use for one-shot entrance animations.
backwardsDuring the delay, the element gets the first keyframe styles immediately — so there's no jump when the animation starts.
bothBoth forwards and backwards. The most commonly useful value for entrance animations with a delay.
🎨 CSS — fill-mode in practice
/* 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.

🎨 CSS — play-state patterns
/* 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; }
Properties Explorer — kf-slide-right
timing
iteration
direction
fill-mode

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.

🎨 CSS — multiple animations
/* 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.

🎨 CSS — stagger patterns
/* 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. */ }
Staggered Animation — pop-in cascade
1
2
3
4
5
6
0s
0.1s
0.2s
0.3s
0.4s
0.5s

10 — Common Animation Patterns

🎨 CSS — ready-to-use @keyframes
/* 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

Accessibility requirement. Vestibular disorders, epilepsy, and motion sensitivity affect a significant portion of users. On Windows, macOS, iOS, and Android, users can enable "Reduce Motion" in system settings. Always respect this preference — unchecked motion is a WCAG 2.1 failure criterion in some contexts and at minimum is inconsiderate to these users.
🎨 CSS — reduced motion patterns
/* 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; } }
Opacity fades are generally safe for motion-sensitive users. Large translates, rotations, and scaling (especially fast ones) are the triggers to avoid. When in doubt, replace the motion with a simple opacity fade rather than removing all animation.

12 — Quick Reference

PropertyValuesNotes
animation-nameKeyframe name or noneMatches a @keyframes rule name
animation-durationTime in s or msRequired — defaults to 0s (instant)
animation-timing-functionease, linear, steps(n), cubic-bezier()Applies to each keyframe segment
animation-delayTime; negative = start partway throughNegative values useful for stagger waves
animation-iteration-countNumber or infiniteCan be a decimal: 1.5 = 1½ plays
animation-directionnormal, reverse, alternate, alternate-reversealternate = natural ping-pong loop
animation-fill-modenone, forwards, backwards, bothUse both for delayed entrance animations
animation-play-staterunning, pausedToggle with a class or :hover
PatternKey properties
Entrance (one-shot)iteration-count: 1; fill-mode: both
Infinite loopiteration-count: infinite; fill-mode: none
Ping-pong loopiteration-count: infinite; direction: alternate
Stagger cascadeIncreasing 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.

Exercise 1
Write a skeleton loading card component. The card should have three placeholder lines (a wide title bar and two narrower text lines). Each placeholder is a <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.
Hint: Use 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.
CSS
@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; } }
Exercise 2
Create a typewriter animation for the string "Hello, World!" (13 characters). The text should appear character by character using 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.
Hint: 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.
CSS
@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; } }
Exercise 3
Build a notification bell icon that plays a "ring" animation once when a class .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.
Hint: Write the shake as percentages 0%→15%→30%→45%→60%→75%→100% alternating ±15deg, ending at 0deg. Set transform-origin: top center on the icon. JS: el.addEventListener('animationend', () => el.classList.remove('ringing')).
CSS + JS
/* 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'); });
Exercise 4
Build a staggered card grid that animates in when a .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.
Hint: The base rule has 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 + JS
/* 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');