Keyframe Animations

Difficulty: Intermediate

While CSS transitions animate between two states (a start and an end), CSS keyframe animations allow you to define complex multi-step animations with full control over intermediate states. Using the @keyframes at-rule, you define named animation sequences with any number of waypoints, each specifying CSS property values at a given point in the timeline. These animations can run automatically on page load, loop indefinitely, run in reverse, and pause/resume - far more flexible than transitions.

The @keyframes rule defines the animation sequence. You give it a name and specify waypoints using percentage values from 0% (start) to 100% (end). The keywords from and to are aliases for 0% and 100% respectively. Each waypoint contains the CSS properties and values the element should have at that point in the timeline. The browser interpolates between waypoints, creating smooth transitions between each defined state. You can add as many waypoints as needed - 25%, 50%, 75%, or any percentage.

To apply an animation, you use the animation-name property on an element, referencing the @keyframes name. The animation-duration specifies how long one cycle takes (required - without it, the animation defaults to 0s and never plays). The animation-iteration-count controls how many times the animation repeats: a number like 3 plays it three times, and the keyword infinite loops it forever. These are the minimum properties needed to get an animation running.

Unlike transitions, keyframe animations run immediately when applied (or after any animation-delay). They do not require a trigger like :hover or a class change. This makes them perfect for loading spinners, attention-grabbing entry animations, continuous ambient effects, and any motion that should occur without user interaction. However, they are heavier than transitions - for simple two-state changes triggered by user actions, transitions remain the better choice.

A common pattern is combining @keyframes with class toggling for triggered animations. You define the animation in CSS and add the class via JavaScript when you want it to play. To replay the animation, you must remove the class, force a reflow (by reading a layout property), and re-add the class. For entry animations, you can define keyframes that start with the hidden state (opacity: 0, translateY) and end with the visible state, applying the animation when elements enter the viewport via the Intersection Observer API.

Code examples

Basic @keyframes Syntax

/* Simple fade-in animation */
@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

.fade-in {
  animation-name: fadeIn;
  animation-duration: 0.5s;
}

/* Multi-step bounce animation */
@keyframes bounce {
  0% {
    transform: translateY(0);
  }
  25% {
    transform: translateY(-30px);
  }
  50% {
    transform: translateY(0);
  }
  75% {
    transform: translateY(-15px);
  }
  100% {
    transform: translateY(0);
  }
}

.bouncing {
  animation-name: bounce;
  animation-duration: 1s;
  animation-iteration-count: infinite;
}

/* Slide in from left with fade */
@keyframes slideInLeft {
  from {
    transform: translateX(-100px);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

.slide-in {
  animation: slideInLeft 0.6s ease-out;
}

@keyframes defines the animation waypoints. 'from' and 'to' are shorthand for 0% and 100%. Multiple percentage stops create complex multi-step animations. The bounce example creates a realistic bouncing effect by varying the height at four points in the timeline.

Loading Spinner with Keyframes

/* Rotating spinner */
@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #e5e7eb;
  border-top-color: #3b82f6;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

/* Pulsing dot loader */
@keyframes pulse {
  0%, 100% {
    transform: scale(1);
    opacity: 1;
  }
  50% {
    transform: scale(1.5);
    opacity: 0.5;
  }
}

.dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #3b82f6;
  animation: pulse 1.2s ease-in-out infinite;
}

/* Stagger dots for a wave effect */
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }

The spinner uses a linear timing function for constant rotation speed. The pulse animation uses grouped percentage stops (0%, 100%) to make start and end identical. Staggered animation-delay on multiple dots creates a wave/ripple loading indicator.

Typing Effect with Keyframes

/* Typewriter animation */
@keyframes typing {
  from {
    width: 0;
  }
  to {
    width: 20ch; /* 20 characters wide */
  }
}

@keyframes blink {
  0%, 100% {
    border-color: transparent;
  }
  50% {
    border-color: #333;
  }
}

.typewriter {
  font-family: monospace;
  font-size: 20px;
  overflow: hidden;
  white-space: nowrap;
  border-right: 3px solid #333;
  width: 0;

  animation:
    typing 2s steps(20, end) forwards,
    blink 0.8s step-end infinite;
}

/* Shake animation for error feedback */
@keyframes shake {
  0%, 100% { transform: translateX(0); }
  10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
  20%, 40%, 60%, 80% { transform: translateX(4px); }
}

.error-shake {
  animation: shake 0.5s ease-in-out;
}

The typewriter effect uses the ch unit (width of the '0' character in the current font) and steps() timing to reveal characters one at a time. The blink animation on the cursor uses step-end for a discrete on/off effect. Multiple animations can be comma-separated on a single element.

Entry Animations with Intersection Observer

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Hidden by default */
.animate-on-scroll {
  opacity: 0;
}

/* Animated when visible */
.animate-on-scroll.visible {
  animation: fadeInUp 0.6s ease-out forwards;
}

/* Staggered children */
.animate-on-scroll.visible:nth-child(1) { animation-delay: 0s; }
.animate-on-scroll.visible:nth-child(2) { animation-delay: 0.15s; }
.animate-on-scroll.visible:nth-child(3) { animation-delay: 0.3s; }

<script>
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      observer.unobserve(entry.target); // Animate once
    }
  });
}, { threshold: 0.1 });

document.querySelectorAll('.animate-on-scroll').forEach(el => {
  observer.observe(el);
});
</script>

Elements start hidden (opacity: 0) and receive the animation when they enter the viewport. IntersectionObserver triggers class addition when 10% of the element is visible. Using unobserve ensures each element animates only once. animation-fill-mode: forwards keeps the final state.

Key points

Concepts covered

@keyframes, animation-name, animation-duration, animation-iteration-count, from/to