9 CSS Text Animation Effects, One Line, Copy-Paste
These nine css text animation effects move a heading into view, into color, or into a short wiggle using only transform, opacity, and filter — no JavaScript,
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Mask reveal from below
- 02 Letter-by-letter bounce
- 03 Rotate-in entrance
- 04 3D letter flip
- 05 Blur to focus
- 06 Wave text
- 07 Wiggle emphasis
- 08 Two-tone sweep
- 09 Glitch
The order below follows what the text itself goes through rather than a popularity ranking. Items 01 through 05 are first-appearance treatments — a mask lift, a letter bounce, a rotate-in, a 3D flip, and a blur-to-focus. Item 06 is built to keep looping once a heading is already on screen, items 07 and 08 pull a second look toward text that never left, and item 09 closes the set by making a phrase look like it briefly broke. Every demo below sits inside a small, non-scrollable iframe and already loops on a fixed cycle, since none of these nine wait on a pointer. Watch the grid above for a couple of seconds and each one has already played through.
01Mask reveal from below
The heading sits inside a box clipped with overflow: hidden, and only the text inside slides up from translateY(115%) to its resting position on a repeating cycle. Because the box itself never moves, nothing else on the page shifts while the reveal happens. It reads best as a hero headline's first entrance or a section title that reveals itself once scrolled into view.
.mask {
overflow: hidden;
max-width: min(380px, 90vw);
}
.mask__text {
color: $color;
animation: mask-up $duration $easing infinite;
}
@keyframes mask-up {
0%, 4% { transform: translateY(115%); }
30%, 78% { transform: translateY(0); }
96%, 100% { transform: translateY(115%); }
}
02Letter-by-letter bounce
Each letter of the heading lives in its own inline span, and a single --i custom property multiplied by 40ms inside animation-delay staggers every letter's identical bounce keyframes. Because the delay math lives on the letters themselves rather than a parent element, adding or removing letters never touches the keyframes. It suits a logo intro or a landing hero title where the letters should feel like they're arriving one at a time.
.letters span {
display: inline-block;
animation: letter-bounce $duration $easing infinite;
animation-delay: calc(var(--i) * 40ms);
}
@keyframes letter-bounce {
0%, 4% { opacity: 0; transform: translateY(22px); }
22%, 70% { opacity: 1; transform: translateY(0); }
88%, 100% { opacity: 0; transform: translateY(-14px); }
}
03Rotate-in entrance
Every letter transitions rotate and opacity together, starting tilted at -25 degrees and fully transparent before settling flat at 0 degrees and full opacity, each one delayed 90ms further than the last through the same --i variable used in item 02. The tilt reads as a letter flying in slightly off-balance rather than sliding in a straight line. Badge labels and short card titles are a natural fit, since the effect reads clearly even across just a handful of characters.
.rot span {
animation: rotate-in $duration $easing infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes rotate-in {
0%, 4% { opacity: 0; transform: rotate(-25deg); }
26%, 72% { opacity: 1; transform: rotate(0deg); }
90%, 100% { opacity: 0; transform: rotate(18deg); }
}
043D letter flip
Inside a parent carrying perspective, each letter's own backface-visibility: hidden lets rotateX swing it from 90 degrees down to 0, so every character flips into place like a tiny card turning face-up. The perspective lives on the shared parent while the rotation and its --i delay live on each letter individually. A price that just updated and a game-style title card both benefit from the flip reading as a discrete change rather than a fade.
.flip {
perspective: 480px;
}
.flip span {
backface-visibility: hidden;
animation: flip-in $duration $easing infinite;
animation-delay: calc(var(--i) * 70ms);
}
@keyframes flip-in {
0%, 4% { opacity: 0; transform: rotateX(90deg); }
30%, 74% { opacity: 1; transform: rotateX(0deg); }
94%, 100% { opacity: 0; transform: rotateX(-90deg); }
}
05Blur to focus
filter: blur runs from 14px down to 0 while opacity climbs from 0 to 1 in the same keyframes block, so the heading looks like a lens racking into focus rather than simply fading in. Position never moves — only sharpness and visibility change, which keeps the layout completely still while the text resolves. Modal titles and photo captions that need to feel deliberate, not instant, are where this one earns its place.
.blur {
animation: blur-focus $duration $easing infinite;
}
@keyframes blur-focus {
0%, 4% { opacity: 0; filter: blur(14px); }
30%, 76% { opacity: 1; filter: blur(0); }
96%, 100% { opacity: 0; filter: blur(14px); }
}
06Wave text
Every letter shares one wave-up keyframes block that moves translateY between 0 and -16px, and only the --i-based animation-delay tells each letter when to start its own copy of that identical motion. Because there's no entrance or exit built in, the line just keeps rippling for as long as it stays on screen. It's built for a loading hint or empty-state text that needs to feel alive while someone waits.
.wave span {
animation: wave-up $duration $easing infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes wave-up {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
07Wiggle emphasis
The whole phrase sits dead still for the first 60% of the loop, then rocks between rotate(-2deg) and translateX(-2px) and their mirrored opposites for a handful of short steps before settling flat again. That long pause between wiggles is deliberate — it reads as an occasional nudge rather than a constant tremor. A hint next to a new badge or a discount label that needs a glance, not a stare, is the right home for it.
.wiggle {
animation: wiggle $duration $easing infinite;
}
@keyframes wiggle {
0%, 60%, 100% { transform: rotate(0) translateX(0); }
66% { transform: rotate(-2deg) translateX(-2px); }
72% { transform: rotate(2deg) translateX(2px); }
78% { transform: rotate(-2deg) translateX(-2px); }
84% { transform: rotate(2deg) translateX(2px); }
90% { transform: rotate(0) translateX(0); }
}
08Two-tone sweep
The same phrase is stacked twice in flat, solid colors, and only the top copy's clip-path wipes from fully hidden to fully shown left-to-right, so the color underneath appears to get painted on rather than blended into it. There's no gradient anywhere in the mix — the edge between the two colors stays hard the entire time. Pricing highlights and campaign copy that need one sharp burst of color change use it well.
.sweep__top {
position: absolute;
inset: 0;
animation: sweep-wipe $duration $easing infinite;
}
@keyframes sweep-wipe {
0%, 4% { clip-path: inset(0 100% 0 0); }
40%, 62% { clip-path: inset(0 0 0 0); }
96%, 100% { clip-path: inset(0 100% 0 0); }
}
09Glitch
Two color-shifted copies of the same phrase sit in mix-blend-mode: screen, and each one gets clip-path sliced into thin horizontal bands that briefly offset with translateX before snapping back into alignment. The two copies run that offset twice per loop rather than once, which is what makes the break read as an interruption instead of a single stumble. Error states and tech-brand logo moments are the two places where a screen genuinely looking broken for an instant actually helps.
.glitch__r, .glitch__b {
position: absolute;
inset: 0;
mix-blend-mode: screen;
}
.glitch__r { animation: glitch-r $duration linear infinite; }
@keyframes glitch-r {
0% { transform: translateX(0); clip-path: inset(0 0 0 0); }
8% { transform: translateX(-6px); clip-path: inset(12% 0 58% 0); }
16% { transform: translateX(4px); clip-path: inset(48% 0 14% 0); }
24% { transform: translateX(0); clip-path: inset(0 0 0 0); }
50% { transform: translateX(-4px); clip-path: inset(20% 0 40% 0); }
58% { transform: translateX(5px); clip-path: inset(55% 0 8% 0); }
66% { transform: translateX(0); clip-path: inset(0 0 0 0); }
}
Where it breaks — the trap
Four of the nine — 02, 03, 04, and 06 — compute their stagger entirely through animation-delay on each letter's own span, driven by that span's own --i value. It's tempting to also drop a plain opacity transition onto the shared parent once the letters are already staggered, expecting the fade to stagger along with them the same way. Instead every letter changes at once, because a transition sitting on the parent has no --i of its own to read — the stagger only ever exists on whichever element's own animation-delay carries it.
Item 09's glitch leans on mix-blend-mode: screen to split its two color layers apart, and screen's own blending math lightens toward white wherever the backdrop underneath is already bright. That's exactly why 09 only ships on a dark stage here — drop the same two layers onto a light background and screen washes the color split out to almost nothing. Swapping in mix-blend-mode: difference, or giving each layer a flat, semi-opaque fill instead of screen, is what keeps the glitch visible on a lighter surface.
Measuring all nine after render backs that emphasis up unevenly: item 06's wave kept moving across 23 of its 24 captured frames, while item 07's wiggle only moved in 7 of them, since it spends most of its loop sitting still on purpose. The nine files here already carry that difference tuned in — grab them from the archive, unlocked with 97me82wy entered exactly as it's spelled right in this sentence, nothing added and nothing left out.
Accessibility
All nine turn off their looping keyframes under prefers-reduced-motion: reduce, but each settles on a different resting frame rather than one shared rule. 01's mask and 05's blur snap straight to their fully revealed, fully sharp end state — transform: none and filter: none put the heading exactly where the animation would have left it anyway. 02, 03, and 04 do the same per letter: animation: none paired with opacity: 1 and transform: none puts every span in its resting position at once, with none of the stagger left to see. 06's wave drops to animation: none and transform: none alone — wave never touched opacity to begin with, so there's nothing to restore. 07's wiggle turns off completely rather than freezing on a rotated frame, since it exists purely to catch a glance and carries no information a static heading would lose. 08 is the one built to keep its outcome legible. Its clip-path locks to inset(0 0 0 0) so the swept-in color shows in full rather than freezing mid-wipe, because here the color itself is the point. 09 sets both glitch layers to opacity: 0, leaving only the plain base text on screen with no split-color layers left to confuse anyone. MDN's write-up on the prefers-reduced-motion media feature covers the operating-system settings behind that toggle.
The rest of this site's animation notes live in the CSS category hub, and what actually ships inside the archive — and what doesn't — is spelled out on the about page.
FAQ
Do these css text animation effects still work if the heading's text length changes?
Yes, for the four that build spans instead of animating the whole line as one block. Items 02, 03, 04, and 06 each recalculate their --i-based stagger on their own, because animation-delay reads each span's own custom property rather than a fixed count baked into the keyframes — add or remove spans and the math still holds. Items 01, 05, 07, 08, and 09 animate the entire heading as a single unit, so a longer or shorter phrase changes nothing about how any of those five run.
Will these work as tailwind css text animation effects, or do they need SCSS?
The demos compile from SCSS down to plain CSS classes ahead of time, so the shipped rules don't depend on a build tool at all — they sit fine next to Tailwind's own utility classes on the same element. All nine are built from keyframes, so each one still needs a small custom animation defined outside Tailwind's default utility set, since staggered per-letter delays aren't something its utilities generate by themselves.
Is there a css text animation generator inside the zip, or just the nine files?
Just the nine — the archive hands over each effect's SCSS, its compiled CSS, and a React version, rather than a tool for dialing up new variants on demand. Changing the pace or the per-letter delay means opening one file's SCSS variables directly, mainly $duration and $stagger, instead of regenerating the animation from a form. The gap between letters itself is a plain clamp() value inline in the same rule, not a named variable, so that one gets edited directly.