Part 2 of 4 · CSS Shine Lab

Keyframe animations

Define a motion's waypoints once in an @keyframes rule and let it run on its own. Looping, multi-step motion a transition can't do.

  • Innovation

Reach for a keyframe animation when the motion should run on its own, with no trigger: a loading spinner, a typing indicator, a shimmer over a placeholder. You define the waypoints once and the same rule can drive as many elements as you like. Like transitions, keyframe animations have shipped in every major browser for well over a decade.

Implementation

A transition animates between two states and needs a trigger, whether that is a :hover, a toggled class, or an element appearing. A keyframe animation is self-contained: you set the waypoints in an @keyframes rule, attach it with the animation shorthand, and it runs. That unlocks things a transition cannot do:

  • More than two steps, with as many percentage stops as you need.
  • Looping, via animation-iteration-count: infinite or any number.
  • Reversing and ping-pong, via animation-direction: reverse or alternate.
  • Plus animation-delay, animation-fill-mode to hold the first or last frame, and animation-play-state to pause.

The four below all run on their own and loop forever, and each drops the animation under prefers-reduced-motion.

A spinner

infinite keeps it running with no interaction at all, the bread-and-butter loading spinner.

View source

HTML

<div class="kf-spinner" role="status" aria-label="Loading"></div>

CSS

.kf-spinner {
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  border: 4px solid var(--color-border-strong);
  border-top-color: var(--color-accent);

  /* Runs on its own, forever — no state change or trigger needed, unlike a
     transition. `infinite` is the looping feature. */
  animation: kf-spin 0.8s linear infinite;
}

@keyframes kf-spin {
  to {
    rotate: 360deg;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kf-spinner {
    animation: none;
  }
}

A skeleton placeholder

A content placeholder while data loads. The animation sweeps a lighter band across each bar by moving an oversized gradient, the familiar shimmer.

View source

HTML

<div class="kf-skeleton" aria-hidden="true">
  <div class="kf-skeleton__line"></div>
  <div class="kf-skeleton__line"></div>
  <div class="kf-skeleton__line kf-skeleton__line--short"></div>
</div>

CSS

.kf-skeleton {
  display: grid;
  gap: 0.6rem;
  width: 100%;
  max-width: 22rem;
}

.kf-skeleton__line {
  height: 0.85rem;
  border-radius: 0.4rem;

  /* A darker base with a lighter band that the animation sweeps across by
     moving the (oversized) background — the classic "shimmer" placeholder. */
  background: linear-gradient(
    90deg,
    var(--color-surface-elevated) 25%,
    var(--color-border-strong) 50%,
    var(--color-surface-elevated) 75%
  );
  background-size: 200% 100%;
  animation: kf-shimmer 1.3s linear infinite;
}

.kf-skeleton__line--short {
  width: 60%;
}

@keyframes kf-shimmer {
  from {
    background-position: 200% 0;
  }
  to {
    background-position: -200% 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kf-skeleton__line {
    animation: none;
  }
}

A typing indicator

The “is typing” bubble from chat apps. One bounce runs on all three dots, offset with animation-delay so it ripples across them.

View source

HTML

<div class="kf-typing" role="status" aria-label="Someone is typing">
  <span class="kf-typing__dot"></span>
  <span class="kf-typing__dot"></span>
  <span class="kf-typing__dot"></span>
</div>

CSS

.kf-typing {
  display: inline-flex;
  gap: 0.4rem;
  padding: 0.75rem 1rem;
  border-radius: var(--radius-full);
  background: var(--color-surface-elevated);
}

.kf-typing__dot {
  width: 0.6rem;
  height: 0.6rem;
  border-radius: 50%;
  background: var(--color-text-secondary);
  animation: kf-typing 1s ease-in-out infinite;
}

/* Stagger the dots with animation-delay so the bounce ripples across them. */
.kf-typing__dot:nth-child(2) {
  animation-delay: 0.15s;
}

.kf-typing__dot:nth-child(3) {
  animation-delay: 0.3s;
}

@keyframes kf-typing {
  0%,
  60%,
  100% {
    translate: 0 0;
    opacity: 0.4;
  }
  30% {
    translate: 0 -0.4rem;
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kf-typing__dot {
    animation: none;
  }
}

An indeterminate progress bar

For when you cannot report a real percentage: a short segment slides across and off, forever, to signal that something is working.

View source

HTML

<div class="kf-progress" role="progressbar" aria-label="Loading">
  <div class="kf-progress__bar"></div>
</div>

CSS

.kf-progress {
  position: relative;
  overflow: hidden;
  width: 100%;
  max-width: 22rem;
  height: 0.5rem;
  border-radius: var(--radius-full);
  background: var(--color-surface-elevated);
}

/* A short segment that slides across and off, over and over — an indeterminate
   progress bar for when you can't report a real percentage. */
.kf-progress__bar {
  position: absolute;
  top: 0;
  left: 0;
  width: 40%;
  height: 100%;
  border-radius: inherit;
  background: var(--color-accent);
  animation: kf-progress 1.4s ease-in-out infinite;
}

@keyframes kf-progress {
  from {
    left: -40%;
  }
  to {
    left: 100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  .kf-progress__bar {
    animation: none;
  }
}

Back to CSS Shine Lab