Keyframe
Keyframe · The log

CSS Text Animation: Gradient Shine, Typewriter, and Per-Letter Effects

July 18, 2026 · Uncategorized

There are two completely different things people mean by css text animation, and mixing them up is why tutorials feel like they contradict each other. One is animating the text block as a single object: fade the whole paragraph, or slide it in. That is regular animation aimed at a box, and the pillar guide covers it. The other is animating the letters, which is the fiddly, interesting, worth-writing-about half. This is about the second.

What css text animation means: the box or the letters

If you want a headline to fade up as a unit, you animate the element and you are done in four lines. If you want a gradient sweeping across the glyphs, a caret typing character by character, or each letter dropping in sequence, you are working at the letter level, and that changes what the code has to do. Three of the effects below need real per-letter elements. The other two do not. Knowing which is which saves you from wrapping spans you never needed.

Gradient shine text with background-clip

The effect everyone wants first: text filled with a moving gradient, like light sweeping across a sign. It looks like it should need canvas. It needs four CSS properties.

You paint a gradient on the element’s background, clip that background to the shape of the glyphs, then make the actual text color transparent so the clipped background shows through. Animate the background position and the gradient slides across the letters.

.shine {
  background: linear-gradient(
    90deg,
    #6b7280 0%,
    #ffffff 50%,
    #6b7280 100%
  );
  background-size: 200% auto;
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  animation: shine 2.5s linear infinite;
}

@keyframes shine {
  from { background-position: 200% center; }
  to   { background-position: 0% center; }
}

Three things make or break it. background-size has to be bigger than 100% or there is nothing to slide; 200% gives you a full sweep. You need both background-clip: text and the -webkit- prefixed version, because the prefixed one is what most engines still read. And linear is the right timing function here; an ease would make the light lurch and stall instead of gliding.

The typewriter effect with steps() and ch

A caret types out a line, character by character, then blinks. The move is animating the element’s width from zero to full, but with a steps() timing function so the reveal jumps one character at a time instead of sliding smoothly and showing half-letters.

The ch unit is the hero. One ch is the width of the 0 glyph in the current font, and in a monospace font that equals one character. Count the characters in your string, set the animation to that many steps, and each step uncovers exactly one.

.type {
  font-family: ui-monospace, monospace;
  width: 22ch;                 /* characters in the string */
  white-space: nowrap;
  overflow: hidden;
  border-right: 2px solid currentColor;
  animation:
    type 2s steps(22, end) forwards,
    caret 0.7s step-end infinite;
}

@keyframes type {
  from { width: 0; }
  to   { width: 22ch; }
}

@keyframes caret {
  50% { border-color: transparent; }
}

steps(22, end) has to match the character count or the timing drifts and you get an extra empty tick at the end. white-space: nowrap keeps the line from wrapping while it is still narrow, and overflow: hidden is what actually hides the untyped part. This only lines up in a monospace font: in a proportional font a w is wider than an i, and ch stops mapping cleanly to characters.

Per-letter staggering

For a headline where each letter drops in sequence, you animate every letter with the same keyframes and offset each one with animation-delay. CSS cannot select “the third letter” of a text node, so the letters have to be real elements. That means wrapping each in a span, which you usually do with a few lines of JavaScript rather than by hand.

const h = document.querySelector('.stagger');
h.innerHTML = [...h.textContent]
  .map((c, i) =>
    `<span style="--i:${i}">${c === ' ' ? '&nbsp;' : c}</span>`
  )
  .join('');
.stagger span {
  display: inline-block;
  opacity: 0;
  animation: drop 0.5s ease-out forwards;
  animation-delay: calc(var(--i) * 60ms);
}

@keyframes drop {
  from { opacity: 0; transform: translateY(-0.4em); }
  to   { opacity: 1; transform: translateY(0); }
}

The custom property --i carries each letter’s index into the CSS, and calc(var(--i) * 60ms) turns it into a staircase of delays. display: inline-block on the spans matters, because transform does nothing on a plain inline element. Preserve the spaces with a non-breaking space or the words weld together.

One cost of the span trick: some screen readers announce a span-per-letter headline as individual letters, which is miserable to listen to. Put the real text in an aria-label on the wrapper and hide the pieces with aria-hidden, or keep this effect for decorative text only.

Wavy letters

Same span setup, different keyframes, and an infinite loop. Give each letter a vertical bob and stagger the delay, and the word waves.

.wave span {
  display: inline-block;
  animation: wave 1.2s ease-in-out infinite;
  animation-delay: calc(var(--i) * 80ms);
}

@keyframes wave {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-0.3em); }
}

The em unit keeps the motion proportional to the font size, so the same rule looks right on a 14px caption and a 72px hero. A pixel offset would make the big text barely twitch and the small text jump off the line.

Gradient text gotchas and the fallback

background-clip: text plus a transparent color has one nasty failure mode. If the clip fails to apply for any reason, color: transparent means invisible text and a blank spot where your headline should be. Set a real color first and treat the transparent value as the enhancement, or accept modern engines as your baseline and test them. For body copy, do not do this at all: animated gradient paragraphs are a headache to read, and a contrast checker will fail them outright.

Performance and reduced motion

Everything here animates transform, opacity, or background-position, so it stays cheap on the compositor. The one to watch is a long headline split into dozens of infinitely looping spans. That is dozens of live animations, and it adds up on a weak GPU. Cap the letter count, or run the entrance once with forwards instead of looping forever.

@media (prefers-reduced-motion: reduce) {
  .shine,
  .type,
  .stagger span,
  .wave span {
    animation: none;
  }
  .type    { width: auto; border-right: none; }
  .stagger span { opacity: 1; }
}

Turning the animations off is not enough for the typewriter and the stagger, because their resting state is hidden: zero width, zero opacity. The reduced-motion block has to also reveal them, or a reader who asked for less motion gets no text at all. That is the part people forget, and it turns a nice-to-have into an accessibility bug.

Match the effect to the job. Shine for a logo or a call to action. Typewriter for a hero line that earns two seconds of attention. Stagger for a headline entrance. Wave for something small and playful. All four are the same handful of properties pointed at type.

← Back to the log