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
- 01 Word-by-word fade-up
- 02 Diagonal wipe mask
- 03 Scale pop-in
- 04 Alternating line slide
- 05 Split-flap board
- 06 Scramble decode
- 07 Tracking squeeze
- 08 Outline to fill
- 09 Shadow build
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).
01Word-by-word fade-up
The sentence breaks into whole words that rise from below one after another, so the eye follows word-sized chunks rather than single letters. Only animation-delay changes from word to word — 0, 160ms, and 320ms — and that stagger is the whole effect.
.ws-word {
display: inline-block;
opacity: 0;
transform: translateY(16px);
}
@keyframes ws-rise {
0% { opacity: 0; transform: translateY(16px); }
20%, 80% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateY(16px); }
}
.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; }
02Diagonal wipe mask
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. The mask is painted in the exact stage color, so a plain translateX on a skewX block is enough, and no seam shows.
.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-out infinite; }
03Scale pop-in
The heading starts small, overshoots its final size, then settles back with a short bounce. The overshoot is a single keyframe near the start, and the spring curve cubic-bezier(.2, 1.4, .4, 1) does the rest.
.sp-text {
opacity: 0;
transform: scale(.4);
}
@keyframes sp-pop {
0% { opacity: 0; transform: scale(.4); }
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
In a two-line block the first line comes in from the left and the second from the right, landing in a zigzag. 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.
.ls-line--l { transform: translateX(-40px); }
.ls-line--r { transform: translateX(40px); }
@keyframes ls-in-r {
0% { opacity: 0; transform: translateX(40px); }
25%, 80% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateX(40px); }
}
.ls-block.is-demo .ls-line--r {
animation: ls-in-r 2s cubic-bezier(.2, .8, .2, 1) infinite;
animation-delay: 160ms;
}
05Split-flap board
Each plate flips down like an airport departure board and stops on the right letter. 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.
.sf-char {
transform-origin: center;
transform: rotateX(90deg);
opacity: 0;
}
@keyframes sf-flip {
0% { transform: rotateX(90deg); opacity: 0; }
30% { transform: rotateX(0deg); opacity: 1; }
90% { transform: rotateX(0deg); opacity: 1; }
100% { transform: rotateX(90deg); opacity: 0; }
}
.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
Random glyphs flicker every 60ms and lock into the real letters from left to right, as if a cipher were being cracked. CSS cannot rewrite text content, so this is the only one of the nine that runs no @keyframes at all and leans on eighteen lines of JavaScript instead.
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
Letters begin spread far apart and transparent, then draw together into a crisp headline. Animating letter-spacing would force a layout pass on every frame, so each character carries its own --x offset and only transform moves.
.tk-ch {
display: inline-block;
opacity: 0;
transform: translateX(var(--x, 0));
}
@keyframes tk-converge {
0% { opacity: 0; transform: translateX(var(--x, 0)); }
35%, 80% { opacity: 1; transform: translateX(0); }
100% { opacity: 0; transform: translateX(var(--x, 0)); }
}
.tk-word.is-demo .tk-ch { animation: tk-converge 2s ease-out infinite; }
08Outline to fill
The letters show up as hollow outlines first, then a fill sweeps in from the left until they are solid. 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.
.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);
}
@keyframes fl-wipe {
0%, 10% { clip-path: inset(0 100% 0 0); }
70%, 90% { clip-path: inset(0 0 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
.fl-wrap.is-demo .fl-text--fill { animation: fl-wipe 2s ease-out infinite; }
09Shadow build
Layers of shadow stack up behind flat text until it reads as extruded, then flatten again. 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.
.sh-text { text-shadow: none; }
@keyframes sh-extrude {
0%, 15% { text-shadow: none; }
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);
}
100% { text-shadow: none; }
}
.sh-text.is-demo { animation: sh-extrude 2s ease-out infinite; }
Where CSS text animation breaks
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 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.
Accessibility: what the nine keep 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. 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.
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 text animations 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.