CSS keyframe animations go beyond transitions โ they can run automatically without user interaction, loop indefinitely, alternate direction, and define complex multi-step motion paths. Where transitions animate between two states, @keyframes animations sequence through as many states as you define. In this lesson you will master the @keyframes rule and all eight animation sub-properties.
Animation Properties
| Property | Controls | Example Values |
|---|---|---|
animation-name |
Which @keyframes rule to use |
fadeIn, spin, pulse |
animation-duration |
One cycle length | 0.5s, 2s |
animation-timing-function |
Acceleration curve per keyframe interval | ease, linear, steps(4) |
animation-delay |
Wait before the first cycle starts | 0s, 0.2s, -0.5s (pre-play) |
animation-iteration-count |
How many times to run | 1, 3, infinite |
animation-direction |
Which direction to play | normal, reverse, alternate, alternate-reverse |
animation-fill-mode |
Which styles apply before/after the animation | none, forwards, backwards, both |
animation-play-state |
Pause or run the animation | running, paused |
animation |
Shorthand for all above | fadeIn 0.5s ease-out forwards |
animation-fill-mode Explained
| Value | Before animation (delay period) | After animation ends |
|---|---|---|
none (default) |
Element styles unchanged | Returns to pre-animation styles |
forwards |
Element styles unchanged | Stays at the final keyframe styles |
backwards |
Applies first keyframe styles immediately | Returns to pre-animation styles |
both |
Applies first keyframe styles immediately | Stays at final keyframe styles |
@keyframes Percentage Syntax
| Keyframe | Aliases | Meaning |
|---|---|---|
from or 0% |
Equivalent | Starting state of the animation |
50% |
Midpoint | Intermediate state at exactly halfway |
to or 100% |
Equivalent | Ending state of the animation |
Multiple: 0%, 100% |
Combined selector | Apply same styles at both keyframes |
animation-delay causes the animation to start already partway through its cycle. For example, animation-delay: -0.5s on a 2-second animation means it begins at the 25% mark. This is useful for staggering looping animations โ each item gets a different negative delay so they appear at different phases of the same cycle.animation-fill-mode: both for enter animations (fade-in, slide-in). The element is invisible during the delay period (applying the from keyframe’s opacity: 0 immediately) and stays visible after the animation completes (keeping the to keyframe’s opacity: 1). Without this, elements flash visible during the delay then snap to invisible before animating.transform and opacity in @keyframes animations for production use. Animating layout properties (width, height, margin, padding) inside keyframes forces a full layout recalculation every single frame โ it will jank on most devices. Colour animations (background-color, color) trigger a paint but not a layout, so they are acceptable with care.Basic Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<title>CSS Keyframe Animations</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', system-ui, sans-serif; background: #0f172a; padding: 48px 32px; }
h3 { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 40px 0 16px; }
.row { display: flex; flex-wrap: wrap; gap: 24px; align-items: center; }
/* โโ 1. Fade in on load โโ */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.6s ease-out both;
}
.fade-in:nth-child(2) { animation-delay: 0.1s; }
.fade-in:nth-child(3) { animation-delay: 0.2s; }
.fade-in:nth-child(4) { animation-delay: 0.3s; }
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 14px;
padding: 20px;
width: 160px;
color: white;
}
.card-icon { font-size: 1.5rem; margin-bottom: 8px; }
.card-label { font-size: 0.8rem; color: #94a3b8; }
/* โโ 2. Infinite spin โโ */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 40px; height: 40px;
border: 4px solid #334155;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* โโ 3. Pulse / breathe โโ */
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.08); opacity: 0.8; }
}
.pulse-dot {
width: 16px; height: 16px;
background: #10b981;
border-radius: 50%;
animation: pulse 1.8s ease-in-out infinite;
}
.pulse-ring {
width: 48px; height: 48px;
border: 3px solid #10b981;
border-radius: 50%;
animation: pulse 1.8s ease-in-out infinite;
}
/* โโ 4. Skeleton loading shimmer โโ */
@keyframes shimmer {
from { background-position: -600px 0; }
to { background-position: 600px 0; }
}
.skeleton {
background: linear-gradient(90deg,
#1e293b 25%,
#334155 50%,
#1e293b 75%
);
background-size: 1200px 100%;
animation: shimmer 1.6s ease-in-out infinite;
border-radius: 8px;
}
.skel-line-lg { height: 16px; width: 200px; margin-bottom: 10px; }
.skel-line-md { height: 12px; width: 140px; margin-bottom: 10px; }
.skel-line-sm { height: 12px; width: 100px; }
.skel-avatar { width: 48px; height: 48px; border-radius: 50%; }
/* โโ 5. Bouncing dots loader โโ */
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.bounce-dots { display: flex; gap: 6px; }
.bounce-dot {
width: 12px; height: 12px;
background: #4f46e5;
border-radius: 50%;
animation: bounce 1.4s ease-in-out infinite both;
}
.bounce-dot:nth-child(1) { animation-delay: -0.32s; }
.bounce-dot:nth-child(2) { animation-delay: -0.16s; }
.bounce-dot:nth-child(3) { animation-delay: 0s; }
/* โโ 6. Progress bar fill โโ */
@keyframes fillBar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-track {
height: 8px; width: 300px;
background: #1e293b;
border-radius: 9999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
border-radius: 9999px;
transform-origin: left;
animation: fillBar 2s cubic-bezier(0.4, 0, 0.2, 1) both;
animation-delay: 0.3s;
}
</style>
</head>
<body>
<h3>Staggered Fade-In on Load</h3>
<div class="row">
<div class="card fade-in"><div class="card-icon">🌟</div><p class="card-label">Card 1</p></div>
<div class="card fade-in"><div class="card-icon">⚡</div><p class="card-label">Card 2</p></div>
<div class="card fade-in"><div class="card-icon">🎉</div><p class="card-label">Card 3</p></div>
<div class="card fade-in"><div class="card-icon">🚀</div><p class="card-label">Card 4</p></div>
</div>
<h3>Loading Indicators</h3>
<div class="row">
<div class="spinner"></div>
<div class="bounce-dots">
<div class="bounce-dot"></div>
<div class="bounce-dot"></div>
<div class="bounce-dot"></div>
</div>
<div class="pulse-dot"></div>
<div class="pulse-ring"></div>
</div>
<h3>Skeleton Shimmer</h3>
<div class="row" style="align-items:flex-start">
<div class="skeleton skel-avatar"></div>
<div>
<div class="skeleton skel-line-lg"></div>
<div class="skeleton skel-line-md"></div>
<div class="skeleton skel-line-sm"></div>
</div>
</div>
<h3>Progress Bar Fill</h3>
<div class="progress-track">
<div class="progress-bar"></div>
</div>
</body>
</html>
How It Works
Step 1 โ animation-fill-mode: both Prevents Flash
The staggered cards use animation: fadeIn 0.6s ease-out both. The both fill-mode applies the from keyframe styles (opacity: 0; translateY(16px)) immediately โ before the delay period. Without this, cards 2โ4 would flash fully visible for 0.1โ0.3 seconds during their delay before suddenly disappearing and animating in.
Step 2 โ Negative Delay Staggers Looping Animations
The three bouncing dots share identical @keyframes and timing but have delays of -0.32s, -0.16s, and 0s. Negative delays pre-play the animation โ each dot is already at a different phase of the cycle when the page loads, creating the continuous wave effect without any JavaScript.
Step 3 โ Skeleton Shimmer Uses background-position
The shimmer animation moves a wide gradient background from far left (-600px) to far right (600px) on the background-position property. The gradient itself is a subtle three-stop light sweep: dark โ lighter โ dark. The element clips the moving gradient, revealing the shimmer travelling across the skeleton.
Step 4 โ Progress Bar Uses scaleX + transform-origin
The bar starts at scaleX(0) (collapsed to zero width from the left) and animates to scaleX(1) (full width). transform-origin: left ensures it grows left to right. Using transform instead of width: 0 to width: 100% keeps the animation GPU-composited.
Step 5 โ animation-delay: 0.3s Lets the Page Settle First
The progress bar has a 0.3s delay before starting. This gives the page a moment to render fully before drawing attention to the animation โ a common UX pattern that prevents the loading indicator from feeling like it starts at the wrong time.
Real-World Example: Toast Notification System
/* toast.css */
@keyframes toastSlideIn {
from { transform: translateX(calc(100% + 24px)); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes toastSlideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(calc(100% + 24px)); opacity: 0; }
}
@keyframes toastProgress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
.toast-container {
position: fixed;
bottom: 24px; right: 24px;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 9999;
}
.toast {
display: flex; align-items: center; gap: 12px;
background: white;
border-radius: 12px;
padding: 14px 18px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
min-width: 280px; max-width: 360px;
border: 1px solid #e2e8f0;
position: relative;
overflow: hidden;
animation: toastSlideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.toast.dismissing {
animation: toastSlideOut 0.3s ease-in forwards;
}
.toast-icon { font-size: 1.2rem; flex-shrink: 0; }
.toast-body { flex: 1; }
.toast-title { font-size: 0.875rem; font-weight: 700; color: #0f172a; }
.toast-desc { font-size: 0.8rem; color: #64748b; margin-top: 2px; }
/* Progress bar timer */
.toast-progress {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 3px;
background: #4f46e5;
transform-origin: left;
animation: toastProgress 4s linear forwards;
}
/* Variants */
.toast-success .toast-progress { background: #10b981; }
.toast-error .toast-progress { background: #ef4444; }
.toast-warning .toast-progress { background: #f59e0b; }
Common Mistakes
Mistake 1 โ Animating without animation-fill-mode: both
โ Wrong โ elements flash at their final state during the delay period:
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.card { animation: fadeIn 0.5s ease 0.2s; }
/* card is visible (opacity:1) for 0.2s, then flashes to opacity:0 and fades in */
โ Correct โ both applies from/to states before and after animation:
.card { animation: fadeIn 0.5s ease 0.2s both; }
Mistake 2 โ Animating layout properties inside keyframes
โ Wrong โ height animation forces layout recalc every frame:
@keyframes expand { from { height: 0; } to { height: 200px; } }
.panel { animation: expand 0.4s ease; overflow: hidden; }
โ Correct โ animate transform (scaleY) instead:
@keyframes expand {
from { transform: scaleY(0); opacity: 0; }
to { transform: scaleY(1); opacity: 1; }
}
.panel { transform-origin: top; animation: expand 0.4s ease both; }
Mistake 3 โ Repeating identical keyframes for stagger instead of using delay
โ Wrong โ duplicating animation definitions for each stagger step:
@keyframes fadeIn1 { from { opacity:0; } to { opacity:1; } }
@keyframes fadeIn2 { 0%{opacity:0} 20%{opacity:0} 100%{opacity:1} }
/* Fragile and unmaintainable */
โ Correct โ one keyframes rule, varied delay per element:
@keyframes fadeIn { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
.item:nth-child(1) { animation: fadeIn 0.5s ease both 0s; }
.item:nth-child(2) { animation: fadeIn 0.5s ease both 0.1s; }
.item:nth-child(3) { animation: fadeIn 0.5s ease both 0.2s; }
Quick Reference
| Property | Key Values | Notes |
|---|---|---|
animation-name |
Name of @keyframes rule | Must match @keyframes identifier exactly |
animation-duration |
0.3s โ 3s |
UI: 0.2โ0.5s; decorative: up to 3s |
animation-iteration-count |
1, infinite |
Loaders use infinite; reveals use 1 |
animation-direction |
alternate |
Ping-pong โ plays forward then backward |
animation-fill-mode |
both |
Use both for nearly all enter/exit animations |
animation-delay |
Positive or negative value | Negative delays pre-play the animation |
animation-play-state |
paused |
Pause on hover: :hover { animation-play-state: paused } |