Skip to main content

CSS Keyframe Animations

While transitions animate between two states, keyframe animations let you define complex, multi-step sequences. They can run automatically, repeat indefinitely, and create effects that transitions simply cannot.

The @keyframes Syntax

Define an animation sequence with @keyframes, then apply it with the animation property:

@keyframes fade-in {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.element {
  animation: fade-in 0.3s ease-out;
}

For multi-step animations, use percentage keyframes:

@keyframes pulse {
  0% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.05);
  }
  100% {
    transform: scale(1);
  }
}

You can list multiple stops at the same percentage to hold a value:

@keyframes progress {
  0%, 10% {
    width: 0%;
  }
  90%, 100% {
    width: 100%;
  }
}

Animation Properties

The animation shorthand combines multiple properties:

.element {
  animation: bounce 0.6s ease-in-out 0.2s infinite alternate both;
  /*        name   duration timing    delay count   direction fill */
}

Here is what each property controls:

PropertyValuesPurpose
animation-namekeyframe nameWhich animation to run
animation-durationtime (e.g., 0.3s)How long one cycle takes
animation-timing-functionease, linear, cubic-bezierSpeed curve
animation-delaytimeWait before starting
animation-iteration-countnumber or infiniteHow many times to repeat
animation-directionnormal, reverse, alternateDirection of playback
animation-fill-modenone, forwards, backwards, bothWhat styles apply before/after
animation-play-staterunning, pausedPlay or pause

Fill Modes

Fill mode is critical for one-shot animations. Without it, the element snaps back to its original state when the animation ends:

/* Without fill mode: element disappears then reappears */
@keyframes slide-in {
  from { transform: translateX(-100%); }
  to { transform: translateX(0); }
}

/* With forwards: element stays at the final keyframe */
.panel {
  animation: slide-in 0.3s ease-out forwards;
}

/* With both: element starts at the first keyframe (even during delay)
   and stays at the last keyframe after completion */
.panel-delayed {
  animation: slide-in 0.3s ease-out 0.5s both;
}

Use forwards when the animation should stick. Use both when there is a delay and the element should start in the first keyframe state.

Practical Animations

Loading Spinner

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

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

Skeleton Screen Shimmer

@keyframes shimmer {
  0% {
    background-position: -200% 0;
  }
  100% {
    background-position: 200% 0;
  }
}

.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
  border-radius: 0.25rem;
}

Staggered List Entry

@keyframes slide-up {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.list-item {
  animation: slide-up 0.4s ease-out both;
}

.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 50ms; }
.list-item:nth-child(3) { animation-delay: 100ms; }
.list-item:nth-child(4) { animation-delay: 150ms; }
.list-item:nth-child(5) { animation-delay: 200ms; }

Staggering creates a cascading effect that feels natural and draws the eye down the list. Keep delays short (30-80ms between items) to avoid feeling sluggish.

Notification Entry

@keyframes notification-in {
  0% {
    transform: translateX(100%);
    opacity: 0;
  }
  60% {
    transform: translateX(-5%);
    opacity: 1;
  }
  100% {
    transform: translateX(0);
  }
}

.notification {
  animation: notification-in 0.4s ease-out both;
}

The slight overshoot at 60% creates a natural deceleration that catches the user's eye.

Choreography

When multiple elements animate, their timing relationships matter. Choreography is the art of making multiple animations feel coordinated:

/* Container fades in first */
.modal-backdrop {
  animation: fade-in 0.2s ease-out both;
}

/* Content slides up slightly after */
.modal-content {
  animation: slide-up 0.3s ease-out 0.1s both;
}

/* Close button appears last */
.modal-close {
  animation: fade-in 0.2s ease-out 0.25s both;
}

Rules for good choreography:

  • Container before content — the backdrop or wrapper should appear before its children
  • Large movements before small ones — primary elements animate first
  • Keep total sequence under 500ms — users should not wait for animations to finish
  • Exit animations are faster than entries — when closing, users want it gone quickly

Reducing Motion

Always provide a reduced-motion alternative:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

This global reset disables all animations for users who request it while still allowing animation-fill-mode: forwards to apply final states instantly.