CSS Animations
Define multi-step motion sequences with @keyframes and the animation shorthand.
1. Introduction
CSS transitions animate between two states. But what if you need a loading spinner that rotates forever? A notification badge that pulses? A skeleton screen that shimmer-loops while content loads? These require CSS animations.
CSS animations use @keyframes to define the steps of a sequence, and the animation property to attach, time, and control that sequence. Unlike transitions, animations can:
- Have any number of intermediate steps
- Start automatically without a user trigger
- Loop indefinitely or a fixed number of times
- Play forwards, backwards, or alternate
- Be paused and resumed with a single property change
2. Theory
2.1 @keyframes — defining the sequence
A @keyframes rule names an animation and describes CSS at specific points in its timeline.
/* Using keyword from/to (0% and 100%) */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Using percentage stops */
@keyframes bounce {
0% { transform: translateY(0); }
40% { transform: translateY(-30px); }
60% { transform: translateY(-15px); }
80% { transform: translateY(-5px); }
100% { transform: translateY(0); }
}
/* Multiple properties at a stop */
@keyframes pop-in {
0% { opacity: 0; transform: scale(0.5); }
80% { transform: scale(1.05); }
100% { opacity: 1; transform: scale(1); }
}
2.2 animation sub-properties
| Property | What it controls | Default |
|---|---|---|
animation-name | Name of the @keyframes rule to use | none |
animation-duration | How long one cycle takes | 0s |
animation-timing-function | Easing curve (same values as transition) | ease |
animation-delay | Wait before the first cycle starts | 0s |
animation-iteration-count | Number of cycles (infinite or a number) | 1 |
animation-direction | normal / reverse / alternate / alternate-reverse | normal |
animation-fill-mode | Styles before/after animation (none/forwards/backwards/both) | none |
animation-play-state | running or paused | running |
2.3 The animation shorthand
/* animation: name duration timing-function delay iteration-count direction fill-mode */
.spinner {
animation: spin 1s linear infinite;
}
.alert-badge {
animation: pulse 1.5s ease-in-out 0.5s 3 alternate forwards;
}
/* Multiple animations on one element */
.fancy {
animation: fade-in 0.5s ease, slide-up 0.5s ease;
}
2.4 animation-fill-mode
This is often confusing. It controls what styles apply outside the animation duration:
none— element returns to its pre-animation styles after the animation ends (default).forwards— element retains the styles of the last keyframe after the animation ends.backwards— element applies the first keyframe styles during the delay period.both— applies bothforwardsandbackwardsbehaviour.
/* Without forwards: element jumps back to opacity: 0 after fading in */
.intro { animation: fade-in 1s ease; }
/* With forwards: element stays at opacity: 1 */
.intro { animation: fade-in 1s ease forwards; }
2.5 animation-direction: alternate
alternate makes the animation reverse direction on even-numbered iterations — perfect for pulsing and breathing effects:
@keyframes pulse-scale {
from { transform: scale(1); }
to { transform: scale(1.1); }
}
.pulse { animation: pulse-scale 0.8s ease-in-out infinite alternate; }
2.6 Pausing animations with play-state
.loader { animation: spin 1s linear infinite; }
.loader.paused { animation-play-state: paused; }
2.7 Staggering animations with delay
Give each item in a list a slightly different animation-delay to create a stagger effect:
.list-item:nth-child(1) { animation: slide-in 0.4s ease both; }
.list-item:nth-child(2) { animation: slide-in 0.4s ease 0.1s both; }
.list-item:nth-child(3) { animation: slide-in 0.4s ease 0.2s both; }
/* Or more scalably with CSS custom properties / JS */
2.8 Common named animation patterns
/* --- Spinner --- */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* --- Fade in from below --- */
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* --- Shimmer (skeleton screen) --- */
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
/* --- 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); }
}
3. Real World Example
- Loading spinners —
rotate(360deg)looping infinitely. - Skeleton screens — shimmer gradient while content loads (used by Facebook, LinkedIn, YouTube).
- Toast / notification badges — slide in from the top-right, linger, then fade out with
forwards. - Page load animations — hero text fades in and slides up with
animation-delaystaggers. - Error shake — a login form field shakes horizontally when credentials fail.
- Success checkmark — SVG stroke-dashoffset draws the checkmark using a combination of CSS animation and SVG properties.
- Attention pulse — a live indicator dot pulses with
alternate+scaleto signal real-time activity.
4. Code Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CSS Animations Demo</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; padding: 2rem; background: #f0f0f0; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1.5rem; }
.box {
background: white; border-radius: 10px;
padding: 1.25rem; text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,.1);
}
.label { font-size: 0.75rem; color: #666; margin-bottom: 0.75rem; }
/* ---- 1. Spinner ---- */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 40px; height: 40px; margin: 0 auto;
border: 4px solid #ddd;
border-top-color: royalblue;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* ---- 2. Fade + slide up ---- */
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-text {
font-size: 1.1rem; font-weight: 700;
animation: slide-up 0.6s ease both;
}
/* ---- 3. Pulse ---- */
@keyframes pulse { from { transform: scale(1); } to { transform: scale(1.15); } }
.dot {
width: 16px; height: 16px; margin: 0 auto;
background: limegreen; border-radius: 50%;
animation: pulse 0.8s ease-in-out infinite alternate;
}
/* ---- 4. Skeleton shimmer ---- */
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.skeleton {
height: 14px; border-radius: 4px;
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
margin-bottom: 8px;
}
.skeleton.short { width: 60%; }
/* ---- 5. Shake ---- */
@keyframes shake {
0%,100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-5px); }
80% { transform: translateX(5px); }
}
.shake-input {
width: 100%; padding: 0.4rem; border: 2px solid #ccc;
border-radius: 6px; font-size: 0.9rem;
}
.shake-input.error {
border-color: crimson;
animation: shake 0.4s ease;
}
/* ---- 6. Staggered list ---- */
@keyframes pop-in {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
.stagger-list { list-style: none; text-align: left; }
.stagger-list li {
background: royalblue; color: white;
margin-bottom: 4px; padding: 0.3rem 0.6rem;
border-radius: 4px;
animation: pop-in 0.3s ease both;
}
.stagger-list li:nth-child(1) { animation-delay: 0s; }
.stagger-list li:nth-child(2) { animation-delay: 0.1s; }
.stagger-list li:nth-child(3) { animation-delay: 0.2s; }
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
</style>
</head>
<body>
<div class="grid">
<div class="box">
<p class="label">Spinner</p>
<div class="spinner"></div>
</div>
<div class="box">
<p class="label">Slide-up text</p>
<p class="hero-text">Hello World</p>
</div>
<div class="box">
<p class="label">Live indicator</p>
<div class="dot"></div>
</div>
<div class="box">
<p class="label">Skeleton loader</p>
<div class="skeleton"></div>
<div class="skeleton short"></div>
<div class="skeleton"></div>
</div>
<div class="box">
<p class="label">Error shake (click)</p>
<input class="shake-input" id="shake-input" type="text" placeholder="Type anything">
<button onclick="
const el = document.getElementById('shake-input');
el.classList.remove('error');
void el.offsetWidth; /* force reflow to restart animation */
el.classList.add('error');
" style="margin-top:6px;padding:4px 8px;cursor:pointer">Shake</button>
</div>
<div class="box">
<p class="label">Stagger list</p>
<ul class="stagger-list">
<li>Item one</li>
<li>Item two</li>
<li>Item three</li>
</ul>
</div>
</div>
</body>
</html>
5. Code Breakdown
Spinner (lines 15–22)
The spinner is a circle with a transparent border except for the top edge (border-top-color: royalblue). animation: spin 0.8s linear infinite rotates it continuously. linear is used because constant speed looks best for spinners — ease would cause visible acceleration/deceleration.
fill-mode: both (slide-up, line 29)
animation: slide-up 0.6s ease both — both applies the first keyframe's styles (opacity: 0; translateY(20px)) before the animation starts. Without this, the element would be visible at its final position for a split second before the animation begins.
alternate direction (pulse, lines 32–37)
infinite alternate makes the dot grow from scale(1) to scale(1.15) then shrink back — without needing to define the return journey in the keyframes. The two-keyword shorthand combines animation-iteration-count: infinite and animation-direction: alternate.
Shimmer gradient (lines 39–48)
A linear-gradient with a light band in the middle creates the shimmer. background-size: 200% makes the gradient wider than the element. Animating background-position from -200% to 200% slides the highlight across. This technique is a paint operation, but it is acceptable because skeleton screens are used before any real content renders.
Restarting an animation (shake, inline JS)
To replay an animation that already completed: remove the class, force a layout reflow with void el.offsetWidth, then re-add the class. Without the reflow, the browser batches the class changes and the animation does not restart.
Stagger with nth-child delays (lines 60–65)
Each list item uses the same pop-in keyframe but with an increasing animation-delay. fill-mode: both hides them until their delay fires, preventing all items from briefly flashing at their final state.
6. Common Mistakes
Mistake 1 — Forgetting animation-fill-mode: forwards for one-shot animations
/* Bad — element snaps back to invisible after fading in */
.banner { animation: fade-in 1s ease; }
/* Good — holds the final state */
.banner { animation: fade-in 1s ease forwards; }
Mistake 2 — Animating layout properties in @keyframes
/* Bad — triggers layout every frame */
@keyframes grow { from { width: 0; } to { width: 200px; } }
/* Better — use transform */
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
Mistake 3 — Trying to restart an animation without a reflow
/* Bad — browser collapses the two changes, animation doesn't replay */
el.classList.remove('shake');
el.classList.add('shake');
/* Good — force reflow between */
el.classList.remove('shake');
void el.offsetWidth;
el.classList.add('shake');
Mistake 4 — Not providing a reduced-motion fallback
Vestibular disorders can make large motion animations physically uncomfortable. Always include a prefers-reduced-motion: reduce block.
Mistake 5 — Using wrong fill-mode for delayed stagger items
Without fill-mode: backwards (or both), stagger items are briefly visible at their final opacity before their delay fires. Use animation: name duration easing delay both to hide them during the delay.
Mistake 6 — Infinite animations on every page element
Excessive infinite animations drain battery, especially on mobile. Use them sparingly — only where they communicate meaningful status (loading, live data).
7. Best Practices
- Name keyframes descriptively —
slide-in-from-leftis clearer thananim1. - Use transform and opacity inside keyframes to keep animations on the compositor thread.
- Always add fill-mode: both to entrance animations so the element holds its starting state during any delay.
- Use animation-direction: alternate for looping effects instead of duplicating keyframes in reverse.
- Keep iteration-count finite unless the animation communicates continuous status (spinners, live indicators).
- Stagger with delay to guide the user's eye through lists and grids — max ~100 ms per item.
- Add prefers-reduced-motion override — disable or simplify all animations for users who prefer it.
- Restart trick — always use
void el.offsetWidthbetween removing and re-adding an animation class. - Use animation-play-state: paused to pause animations when they are off-screen (e.g., with IntersectionObserver).
- Test at real 60 fps — use DevTools "FPS meter" and throttle CPU to catch jank early.
8. Practice Exercise
Build a loading screen with multiple animations.
Requirements
- Create a full-screen loading overlay with:
- A circular spinner (
spinkeyframe,linear infinite) - "Loading..." text that fades in below the spinner
- A progress bar that grows from 0% to 100% width over 3 seconds then holds (
forwards)
- A circular spinner (
- After 3 seconds, hide the overlay and reveal the page content with a
slide-upanimation. - Add a skeleton screen for a card component (shimmer gradient on a placeholder block).
- Create an error shake animation triggered by clicking a "Submit" button on an empty input.
- Add a
prefers-reduced-motionoverride block that disables all animations.
Bonus
- Stagger the reveal of three cards with increasing delays when the page loads.
- Add a "success" animation — a green checkmark that draws itself using
stroke-dashoffseton an SVG path.
9. Assignment
Animate a landing page hero section.
- Build a hero section with: a background image/gradient, a headline, a subtitle, and a CTA button.
- On page load, animate each element in sequence:
- Background fades in (0.8s)
- Headline slides up from below (0.5s, delay 0.3s)
- Subtitle fades in (0.5s, delay 0.6s)
- Button pops in with a slight bounce (0.4s, delay 0.9s)
- Use
animation-fill-mode: bothon all elements so they stay hidden during their delay. - The CTA button should have a subtle infinite pulse animation after it appears.
- Add a scroll-triggered class (use JavaScript + IntersectionObserver) that plays a
fade-inanimation when a "Features" section scrolls into view. - Include a
prefers-reduced-motionblock.
Deliverable: One HTML file with embedded CSS and JS. Include a comment describing each animation and why you chose its timing.
10. Interview Questions
- What is the difference between a CSS transition and a CSS animation?
Transitions animate between exactly two states and are triggered by a property change. Animations (@keyframes) can have multiple intermediate steps, run automatically, loop, and alternate direction. - What does animation-fill-mode: forwards do?
It keeps the styles from the last keyframe after the animation ends. Without it, the element snaps back to its pre-animation styles. - How would you restart a CSS animation that has already played?
Remove the animation class, force a reflow (void el.offsetWidth), then re-add the class. The reflow forces the browser to re-process the class removal before applying the new class. - What is animation-direction: alternate used for?
It reverses the keyframe direction on even iterations, creating a ping-pong effect. This is useful for pulse or breathing animations without duplicating keyframes in reverse. - What is animation-play-state and when would you use it?
It pauses (paused) or resumes (running) an animation without removing it. Useful for pausing animations when an element is off-screen or when the tab is not focused. - How do you create a stagger animation for a list?
Apply the same @keyframes animation to all items but give each a different animation-delay — increasing by a fixed amount per item (e.g., 0.1s per item). - Why might you use animation-fill-mode: both instead of forwards?
both applies backwards (first keyframe during delay) AND forwards (last keyframe after end). This prevents elements from flashing at their default state during the delay period in stagger effects.
11. Additional Resources
- MDN — Using CSS animations — comprehensive reference with examples
- MDN — @keyframes — full syntax reference
- CSS Tricks — A complete guide to CSS animations
- Animate.css — popular animation library to study well-crafted keyframe patterns
- motion.dev (formerly Framer Motion) — for advanced JavaScript-driven animations
- Google web.dev — Animations guide — performance and best practices
- Keyframes.app — visual keyframe editor that outputs CSS