GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

9 CSS Text Animation Effects for Reveals

A CSS text animation is the short movement a heading or a sentence makes as it arrives on screen.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine are not ordered by popularity. They follow what a sentence goes through as it reaches the reader: first the quiet entrances that rise or slide in by word and by line (01 to 04), then two louder arrivals — a mechanical flip board and a decoding cipher (05 and 06) — and finally three that work on a heading already sitting there, closing its tracking, filling its outline, or stacking shadows behind it (07 to 09). When the whole panel turns instead of the letters, the card flip animations take over, and card surfaces that answer the cursor are gathered in the CSS card hover effects.

01Word-by-word fade-up

Text reveal animation css, word by word

The sentence breaks into whole words that rise from below one after another, so the eye follows word-sized chunks rather than single letters.

Word-by-word fade-up
Open in a new tab

01 · Word-by-word fade-up — Measured area 5.099% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

display:inline-blocktranslateYanimation-delay

Only the delay changes

Only animation-delay changes from word to word — 0, 160ms, and 320ms — and that stagger is the whole effect. Each entrance runs on an inertial lerp curve that slightly overshoots and settles, while per-word blur depth (6→9→12px) makes heavier words focus later.

.ws-word {
  display: inline-block;
  opacity: 0;
  transform: translateY(16px);
  filter: blur(var(--blur, 6px));
}
@keyframes ws-rise {
  0% { opacity: 0; transform: translateY(16px); filter: blur(var(--blur, 6px)); animation-timing-function: $ease-lerp-settle; }
  20%, 80% { opacity: 1; transform: none; filter: blur(0); }
  100% { opacity: 0; transform: translateY(16px); filter: blur(var(--blur, 6px)); }
}
.ws-line.is-demo .ws-word { animation: ws-rise 2s ease-out infinite; }
.ws-line.is-demo .ws-word:nth-child(2) { animation-delay: 160ms; --blur: 9px; }
.ws-line.is-demo .ws-word:nth-child(3) { animation-delay: 320ms; --blur: 12px; }

02Diagonal wipe mask

A slanted edge sweeping across

A slanted edge sweeps across the line and leaves the text behind it, so the line looks cut open on a diagonal rather than lifted like a curtain.

Diagonal wipe mask
Open in a new tab

02 · Diagonal wipe mask — Measured area 2.394% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

skewX(-18deg)transition0.7s

Why no seam shows

The mask is painted in the exact stage color, so a plain translateX on a skewX block is enough, and no seam shows. The mask settles on an inertial lerp curve, slightly overshooting its resting point before it comes to rest.

.dw-mask {
  position: absolute;
  inset: -20% -10%;
  background: $stage-yellow;
  transform: skewX(-18deg) translateX(0);
}
@keyframes dw-sweep {
  0%, 10% { transform: skewX(-18deg) translateX(0); }
  45%, 70% { transform: skewX(-18deg) translateX(140%); }
  100% { transform: skewX(-18deg) translateX(0); }
}
.dw-wrap.is-demo .dw-mask { animation: dw-sweep 2s $ease-lerp-settle infinite; }

03Scale pop-in

Overshoot, then settle

The heading starts small, overshoots its final size, then settles back with a short bounce.

Scale pop-in
Open in a new tab

03 · Scale pop-in — Measured area 6.413% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

scalecubic-bezierovershoot

One keyframe and one curve

The overshoot is a single keyframe near the start, and the spring curve cubic-bezier(.2, 1.4, .4, 1) does the rest. The entrance runs on an inertial lerp curve that slightly overshoots the target before settling, so the growth carries a sense of weight.

.sp-text {
  opacity: 0;
  transform: scale(.4);
}
@keyframes sp-pop {
  0% { opacity: 0; transform: scale(.4); animation-timing-function: $ease-lerp-settle; }
  18% { opacity: 1; transform: scale(1.1); }
  30% { transform: scale(.96); }
  40%, 78% { transform: scale(1); opacity: 1; }
  100% { opacity: 0; transform: scale(.4); }
}
.sp-text.is-demo { animation: sp-pop 2s cubic-bezier(.2, 1.4, .4, 1) infinite; }

04Alternating line slide

Two lines from opposite sides

In a two-line block the first line comes in from the left and the second from the right, landing in a zigzag.

Alternating line slide
Open in a new tab

04 · Alternating line slide — Measured area 7.392% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXanimation-delay: 160msalternating delay

No script for more lines

Two translateX rules of the same 40px with opposite signs and a 160ms gap are the entire mechanism, and a modifier class (--l or --r) sets each line's direction, so more lines need no script. Each line settles on an inertial lerp curve while blur(7px)→0 runs over the same span.

.ls-line--l { transform: translateX(-40px); }
.ls-line--r { transform: translateX(40px); }
@keyframes ls-in-r {
  0% { opacity: 0; transform: translateX(40px); filter: blur(7px); }
  25%, 80% { opacity: 1; transform: none; filter: blur(0); }
  100% { opacity: 0; transform: translateX(40px); filter: blur(7px); }
}
.ls-block.is-demo .ls-line--r {
  animation: ls-in-r 2s $ease-lerp-settle infinite;
  animation-delay: 160ms;
}

05Split-flap board

Plates flipping to a stop

Each plate flips down like an airport departure board and stops on the right letter.

Split-flap board
Open in a new tab

05 · Split-flap board — Measured area 5.078% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotateXsteps()perspective

Timing, not transform

The mechanical feel lives in the timing rather than the transform: steps(3, end) chops a plain rotateX into three hard stages, and swapping it for an ease curve turns the effect into an ordinary smooth 3D flip. A blur pass mimics motion blur: the plate softens edge-on and sharpens as it lands.

.sf-char {
  transform-origin: center;
  transform: rotateX(90deg);
  opacity: 0;
  filter: blur(3px);
}
@keyframes sf-flip {
  0% { transform: rotateX(90deg); opacity: 0; filter: blur(3px); }
  15% { filter: blur(4px); }
  30% { transform: rotateX(0deg); opacity: 1; filter: blur(0); }
  90% { transform: rotateX(0deg); opacity: 1; filter: blur(0); }
  95% { filter: blur(4px); }
  100% { transform: rotateX(90deg); opacity: 0; filter: blur(3px); }
}
.sf-board.is-demo .sf-char { animation: sf-flip 2s steps(3, end) infinite; }
.sf-board.is-demo .sf-char:nth-child(2) { animation-delay: 120ms; }

06Scramble decode

Glyphs locking left to right

Random glyphs flicker every 60ms and lock into the real letters from left to right, as if a cipher were being cracked.

Scramble decode
Open in a new tab

06 · Scramble decode — Measured area 2.665% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

setIntervalJS 12줄monospace

The one item CSS cannot do

CSS cannot rewrite text content, so this is the only one of the nine that runs no @keyframes at all and leans on JavaScript instead. The number of locked letters is itself the progress value, so no separate easing math is needed.

var frame = 0;
setInterval(function () {
  frame++;
  var locked = Math.max(0, Math.floor((frame % 26) / 3) - 2);
  var out = "";
  for (var i = 0; i < target.length; i++) {
    out += i < locked ? target[i] : glyphs[Math.floor(Math.random() * glyphs.length)];
  }
  el.textContent = out;
}, 60);

07Tracking squeeze

Spread apart, then drawn in

Letters begin spread far apart and transparent, then draw together into a crisp headline.

Tracking squeeze
Open in a new tab

07 · Tracking squeeze — Measured area 3.327% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

custom propertytranslateXopacity

Why not letter-spacing

Animating letter-spacing would force a layout pass on every frame, so each character carries its own --x offset and only transform moves. The convergence settles on an inertial lerp curve while blur clears as the tracking tightens.

.tk-ch {
  display: inline-block;
  opacity: 0;
  transform: translateX(var(--x, 0));
  filter: blur(6px);
}
@keyframes tk-converge {
  0% { opacity: 0; transform: translateX(var(--x, 0)); filter: blur(6px); }
  35%, 80% { opacity: 1; transform: translateX(0); filter: blur(0); }
  100% { opacity: 0; transform: translateX(var(--x, 0)); filter: blur(6px); }
}
.tk-word.is-demo .tk-ch { animation: tk-converge 2s $ease-lerp-settle infinite; }

08Outline to fill

Hollow first, filled after

The letters show up as hollow outlines first, then a fill sweeps in from the left until they are solid.

Outline to fill
Open in a new tab

08 · Outline to fill — Measured area 2.835% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

-webkit-text-strokeclip-pathoverflow:hidden

Two layers, one clip-path

An outline layer drawn with -webkit-text-stroke sits underneath, and the colored copy on top is held back by a clip-path that opens from a full inset to zero. The fill sharpens blur(6px)→0 as it flows in, like ink soaking into the letterforms.

.fl-text--outline {
  color: transparent;
  -webkit-text-stroke: 1px $color;
}
.fl-text--fill {
  position: absolute;
  inset: 0;
  color: $color;
  clip-path: inset(0 100% 0 0);
  filter: blur(6px);
}
@keyframes fl-wipe {
  0%, 10% { clip-path: inset(0 100% 0 0); filter: blur(6px); }
  55%, 70% { clip-path: inset(0 0 0 0); filter: blur(0); }
  90% { clip-path: inset(0 0 0 0); filter: blur(0); }
  100% { clip-path: inset(0 100% 0 0); filter: blur(6px); }
}
.fl-wrap.is-demo .fl-text--fill { animation: fl-wipe 2s ease-out infinite; }

09Shadow build

Shadows stacking into depth

Layers of shadow stack up behind flat text until it reads as extruded, then flatten again.

Shadow build
Open in a new tab

09 · Shadow build — Measured area 4.672% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

text-shadowmultiple layerstransition

One property, five copies

Four offset copies from 1px to 4px plus one blurred layer are written as a single text-shadow value, so the whole build is one property changing. The stack settles on an inertial lerp curve — slightly past the target thickness and back — while blur clears as depth grows.

.sh-text { text-shadow: none; filter: blur(5px); }
@keyframes sh-extrude {
  0%, 15% { text-shadow: none; filter: blur(5px); }
  55%, 85% {
    text-shadow:
      1px 1px 0 $subject-blue,
      2px 2px 0 $subject-blue,
      3px 3px 0 $subject-blue,
      4px 4px 0 $subject-blue,
      5px 5px 8px rgba(0, 0, 0, .35);
    filter: blur(0);
  }
  100% { text-shadow: none; filter: blur(5px); }
}
.sh-text.is-demo { animation: sh-extrude 2s $ease-lerp-settle infinite; }

Where CSS text animation breaks

A fill layer that hid the outline

The first item that actually broke was 08. The colored copy of the letters sat over the outline copy with nothing holding it back, and because it was absolutely positioned it covered the outline completely, so the outline stage never reached the screen. From the outside the loop looked healthy; opening the captured frames one by one showed frame 03 with a single stray colored letter on an otherwise empty stage.

The opposite move that worked

The instinct was to keep everything on transform by sliding a cover in the stage color away from the fill, but that cover was exactly what hid the step worth showing. What worked was the opposite move: putting clip-path on the colored layer itself and widening it from the left, which finally let the outline step show before the fill. The corrected sources for all nine are in the archive, which opens with the password jagvrc78 and holds a React copy of every item next to the plain CSS one.

Stacking shadows per letter until the budget breaks meets the same ceiling, surface by surface, in the skeuomorphism landing kit, where texture piles up in layers.

Accessibility: what the nine keep under reduced-motion

What holds under reduced motion

Under prefers-reduced-motion: reduce all nine stop moving and hold their finished state. The eight built on SCSS @keyframes pin that state with !important: words fully up, the diagonal mask parked past the text, tracking closed, the outline filled, the shadows stacked.

Why 06 checks matchMedia

A reader who has turned motion off still gets the result rather than a blank stage. Number 06 has no CSS animation to switch off, so the script checks matchMedia itself and prints the finished word without ever scrambling it.

Where to go next

Other ways to bring letters in one at a time are collected in 9 CSS text animation effects, and the wider set lives in the CSS-only category. The syntax used in 08 is documented in the MDN clip-path reference.

FAQ

Do css text animation examples need JavaScript?

Eight of the nine run on SCSS @keyframes alone. Number 06 is the exception, because it has to replace the text content itself and CSS has no way to do that, so eighteen lines of script handle the swap.

For a headline, should I use 01 or 04?

A headline of one or two lines reads better split by word, the way 01 does. A manifesto or mission statement of two or three lines suits 04, where whole lines enter from alternating sides and give the block a rhythm that word-level staggering cannot match.

Is the React code different from the vanilla code?

The motion itself is the same in both. What differs is the trigger: the vanilla files force an endless loop through an is-demo class so the effect can be filmed, while each React component watches with an IntersectionObserver and plays once when the element scrolls into view. To make the reveal read like a cipher resolving, the decode text reveal effects are that variation.

Enter the archive password

The password is inside this article. You will find it as you read.