CSS Keyframe Animations

โ–ถ Try It Yourself

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
Note: A negative 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.
Tip: Use 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.
Warning: Only animate 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;  }

▶ Try It Yourself

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 }

🧠 Test Yourself

You have a fade-in animation with animation-delay: 0.5s but the element briefly flashes visible before the animation starts. Which fix prevents this?





โ–ถ Try It Yourself