Most text animation on the web is the same three tricks wearing different clothes: fade something in, slide something up, maybe throw a blur on it. I wanted to know how far past those three tricks you could actually go without reaching for a heavy animation library, so I built twenty-nine of them, side by side, in plain CSS and JavaScript. Some are one property. Some are a canvas loop. All of them run without a single dependency.
Here’s what I learned building all twenty-nine, organized by what they’re actually for: pulling a reader’s eye to new content, holding their attention on something static, responding to their cursor, or clearing the stage for what comes next.
As promised, here are all the examples.
Reveal: getting text to arrive instead of just appearing
The simplest possible text animation is opacity: 0 to opacity: 1. It works. It's also invisible as a design choice, because every site does it. The interesting question is what happens between 0 and 1.
Scramble
This animation replaces letters with noise before resolving them, left to right, like a decoding sequence:
function run() {
let revealed = 0;
let frame = 0;
timer = setInterval(() => {
frame++;
let out = "";
for (let i = 0; i < target.length; i++) {
if (target[i] === " ") {
out += " ";
continue;
}
out += i < revealed ? target[i] : CHARS[randInt(0, CHARS.length - 1)];
}
el.textContent = out;
if (frame % 3 === 0) revealed++;
if (revealed > target.length) clearInterval(timer);
}, 40);
}
The trick is the frame % 3 check. Without it, a letter locks in on every single tick, and the whole word resolves in a blink. Slowing the reveal rate independently of the redraw rate is what makes it read as decoding rather than flickering.
Stagger Fade and Blur Focus
Both animations split text into individual character spans and animate each one on a delay proportional to its position:
function splitToSpans(el, textOverride, staggerMax) {
const chars = [...text];
const len = Math.max(chars.length - 1, 1);
chars.forEach((ch, i) => {
const s = document.createElement("span");
s.className = "ch";
if (staggerMax) {
s.style.setProperty("--d", ((i / len) * staggerMax).toFixed(0) + "ms");
}
s.textContent = ch === " " ? "\u00A0" : ch;
frag.appendChild(s);
});
}
.stage[data-anim="blur"] .ch {
opacity: 0;
filter: blur(14px);
transform: scale(1.06);
transition: opacity 0.6s ease, filter 0.6s ease, transform 0.6s ease;
transition-delay: var(--d, 0ms);
}
Notice the delay is computed once, in JavaScript, then handed to CSS as a custom property. The animation itself is pure CSS transition; JavaScript’s only job is figuring out how long each letter should wait its turn. This split matters because it means the animation performance comes from the browser’s transition engine, not from a JavaScript timing loop trying to keep twenty things in sync.
3D Flip-In
This one takes the same staggered-span approach and adds a hinge:
.stage[data-anim="flip"] {
perspective: 600px;
}
.stage[data-anim="flip"] .ch {
transform: rotateX(90deg) translateY(6px);
transform-origin: 50% 100%;
transition: transform 0.5s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
perspective on the parent is what gives rotateX something to rotate through. Skip it and the letters just squash flat instead of appearing to fold toward you.
Split-Flap
This is the one that actually earns its comparison to an airport departures board, because each letter cycles through real random characters before landing, at a slightly different speed per letter:
spans.forEach((s, i) => {
const finalChar = target[i];
let count = 0;
const total = randInt(8, 16);
const t = setInterval(() => {
count++;
if (count >= total) {
s.textContent = finalChar;
clearInterval(t);
} else s.textContent = CHARS[randInt(0, CHARS.length - 1)];
}, 45 + i * 4);
});
45 + i * 4 is doing quiet work here. Each letter's interval runs slightly slower than the one before it, so they don't all land on the same frame. That small stagger is the entire difference between "looks like a glitch" and "looks like a mechanism."
Curtain Wipe and Ink Signature
Both fake a reveal without touching the text’s own opacity at all. The curtain is a solid bar sitting on top of the word that shrinks away:
.curtain-bar {
transform-origin: left;
transform: scaleX(1);
transition: transform 0.9s cubic-bezier(0.6, 0, 0.15, 1);
}
.stage[data-anim="curtain"].in .curtain-bar {
transform: scaleX(0);
}
The ink signature draws an SVG path underneath the word using the classic dash offset trick:
.ink-underline path {
stroke-dasharray: 340;
stroke-dashoffset: 340;
transition: stroke-dashoffset 1s ease;
}
.stage[data-anim="ink"].in .ink-underline path {
stroke-dashoffset: 0;
}
Set the dash array and the offset to the same number, and the entire stroke starts invisible. Animate the offset to zero, and the line appears to draw itself. No JavaScript needed for the drawing itself, just the intersection observer that adds the .in class when the element scrolls into view.
Glitch Split
This is the one specimen in this group that runs continuously instead of triggering once, using duplicated text layered in red and cyan with clipped, offset keyframes:
.glitch::before {
content: attr(data-text);
color: #ff3b6b;
transform: translate(-2px, 0);
mix-blend-mode: screen;
animation: glitchTop 2.6s infinite linear;
}
@keyframes glitchTop {
0%,
89%,
100% {
clip-path: inset(0 0 0 0);
transform: translate(0, 0);
}
90% {
clip-path: inset(10% 0 60% 0);
transform: translate(-3px, -1px);
}
}
The 0%, 89%, 100% selector is the key. Ninety percent of the animation cycle is a clean, un-glitched hold. The glitch itself lives in a narrow 10% window. That ratio is what keeps it readable as text with an occasional artifact, rather than an unreadable flicker.
Loop: text that keeps moving because it’s meant to be looked at, not read
Reveal animations run once and stop. Loop animations are for text that needs to hold attention indefinitely, a hero headline, a status indicator, a ticker.
Wave
This staggers a vertical bounce per character using the same --i index every span already carries:
.stage[data-anim="wave"].in .ch {
animation: waveMove 1.8s ease-in-out infinite;
animation-delay: calc(var(--i) * 60ms);
}
animation-delay runs off calc() reading straight from the custom property, so there's no per-letter JavaScript at all beyond the initial split. That's worth noticing: three of the loop animations in this set reuse the exact same span-splitting utility that the reveal animations use. The difference between "letters fade in" and "letters bounce forever" is which CSS rule you attach to .ch, not how you built the letters.
Shimmer
This fakes a light sweep using a gradient clipped to text:
.stage[data-anim="shimmer"] .demo {
background: linear-gradient(
100deg,
var(--paper-dim) 30%,
var(--brass) 45%,
#fff 50%,
var(--brass) 55%,
var(--paper-dim) 70%
);
background-size: 250% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
@keyframes shimmerMove {
0% {
background-position: 200% 0;
}
100% {
background-position: -100% 0;
}
}
The gradient is three times wider than the text (250%), which is what lets the bright band slide across and off the edges cleanly instead of popping in and out abruptly.
Breathing Glow
This is the smallest amount of code in the whole set for one of the more effective results, just an oscillating text-shadow:
@keyframes glowPulse {
0%,
100% {
text-shadow: 0 0 4px rgba(227, 169, 74, 0.15);
}
50% {
text-shadow: 0 0 26px rgba(227, 169, 74, 0.8),
0 0 6px rgba(227, 169, 74, 0.55);
}
}
Two shadow layers at the peak, one wide and soft, one tight and bright, is what sells the glow. A single shadow value just looks like the text got fuzzy.
Liquid Wobble
This treats each letter like it’s suspended in something viscous, skewing and squashing on a cycle:
@keyframes liquidWob {
0%,
100% {
transform: skewX(0) scaleY(1);
}
25% {
transform: skewX(-6deg) scaleY(1.08);
}
50% {
transform: skewX(0) scaleY(0.94);
}
75% {
transform: skewX(6deg) scaleY(1.05);
}
}
Eased Marquee
This replaces the classic linear scroll with a cubic-bezier that accelerates, holds, and decelerates:
@keyframes marqueeMove {
0% {
transform: translateX(6%);
}
45% {
transform: translateX(-40%);
}
50% {
transform: translateX(-40%);
}
95% {
transform: translateX(6%);
}
100% {
transform: translateX(6%);
}
}
That flat stretch from 45% to 50% is a deliberate pause at the far end of the scroll, giving a reader’s eye a moment to land before the track reverses. Pure linear marquees never rest, which is exactly why they read as cheap.
Kinetic Rotator
This is the one loop animation that’s mostly JavaScript, because it’s swapping entire words, not animating one fixed string:
function show() {
el.textContent = words[idx];
el.style.opacity = 0;
el.style.transform = "translateY(18px)";
requestAnimationFrame(() => {
el.style.opacity = 1;
el.style.transform = "translateY(0)";
});
}
The requestAnimationFrame wrapper matters more than it looks like it should. Set the transform and immediately flip opacity in the same tick, and the browser may collapse both style writes into one paint, skipping the transition entirely. Forcing a frame boundary between "set the start state" and "set the end state" is what guarantees the transition actually plays.
Interactive: animations that need a cursor to mean anything
These five don’t animate on their own. They’re built to respond to the reader in real time, which means the JavaScript is doing continuous math on every mousemove event instead of firing once and finishing.
Magnetic Pull
This measures the distance from the cursor to each letter’s center and nudges the letter toward it:
stage.addEventListener("mousemove", (e) => {
spans.forEach((s) => {
const dx = mx - cx,
dy = my - cy;
const dist = Math.max(30, Math.sqrt(dx * dx + dy * dy));
const pull = Math.min(1, 90 / dist);
s.style.transform = `translate(${dx * pull * 0.35}px, ${
dy * pull * 0.35
}px)`;
});
});
Math.max(30, ...) prevents a division that blows up as the cursor gets close to a letter's exact center. Without that floor, 90 / dist approaches infinity and the letter would jump wildly instead of easing toward the cursor.
Weight Morph
This does something you can’t do with a JPEG: it changes the actual weight axis of a variable font in response to proximity.
const dist = Math.abs(mx - cx);
const wght = Math.max(300, 900 - dist * 6);
s.style.fontVariationSettings = `"wght" ${wght}`;
This only works because the font file itself has weight defined as a continuous variable axis rather than a fixed set of files (400, 700, and so on). Static font formats can’t do this at all; there’s no in-between file to interpolate toward.
Cursor Distort
This applies a fisheye skew based on horizontal distance from the pointer, with the skew direction flipping depending on which side the cursor sits:
const dist = mx - cx;
const proximity = Math.max(0, 1 - Math.abs(dist) / 120);
const skew = (dist > 0 ? -1 : 1) * proximity * 18;
Scroll Scrub
This ties a text fill directly to scroll position instead of the cursor, using a gradient clipped to text with a hard stop at a variable percentage:
function update() {
const r = stage.getBoundingClientRect();
let progress = 1 - (r.top + r.height / 2) / (vh + r.height / 2);
stage.style.setProperty("--p", (progress * 100).toFixed(1) + "%");
}
.stage[data-anim="scrub"] .demo {
background: linear-gradient(
90deg,
var(--paper) var(--p, 0%),
var(--paper-dim) var(--p, 0%)
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
The gradient has a hard edge, not a soft one, because both color stops sit at the exact same position (var(--p)). That's what turns a gradient into a fill meter instead of a blur.
Hover Scatter
This is the simplest of the five: split the word into spans, then on mouseenter, throw each one to a random offset and rotation, and let a CSS transition pull it back on mouseleave.
stage.addEventListener("mouseenter", () => {
spans.forEach((s) => {
s.style.transform = `translate(${rand(-40, 40)}px, ${rand(
-30,
30
)}px) rotate(${rand(-40, 40)}deg)`;
});
});
The animation is entirely the CSS transition on .ch; JavaScript only ever sets a target, never runs the motion itself.
Exit: clearing the stage, deliberately
Exit animations get less attention than entrances, but they’re what makes a hover interaction feel complete instead of half finished.
Shatter
This pre-computes a random trajectory per letter, storing it as CSS custom properties the moment the page loads, then triggers the actual motion purely through :hover:
spans.forEach((s) => {
s.style.setProperty("--tx", rand(-120, 120).toFixed(0) + "px");
s.style.setProperty("--ty", rand(-90, 90).toFixed(0) + "px");
s.style.setProperty("--tr", rand(-90, 90).toFixed(0) + "deg");
});
.stage[data-anim="shatter"]:hover .ch {
transform: translate(var(--tx, 0), var(--ty, 0)) rotate(var(--tr, 0deg));
opacity: 0;
}
Precomputing the random values once and storing them as properties means every hover produces the same shatter pattern instead of a new random one each time. That consistency is what makes it feel designed rather than glitchy.
Dissolve and Fold
Both keep the whole word intact and animate it as one block instead of splitting into letters, which is a deliberate contrast to shatter:
.stage[data-anim="dissolve"]:hover .demo {
filter: blur(10px);
opacity: 0;
transform: scale(1.15);
}
.stage[data-anim="fold"]:hover .demo {
transform: rotateX(70deg) scaleY(0.15);
opacity: 0.2;
}
Fold needs perspective on its parent stage for the same reason flip-in does, without it, rotateX just flattens the text vertically instead of appearing to tip away from the viewer.
Terminal Cycle
This loops through typing and deleting several strings, character by character, in an endless cycle that never needs a hover trigger:
function typeWord(word, cb) {
let i = 0;
const t = setInterval(() => {
el.textContent = word.slice(0, i + 1);
i++;
if (i > word.length) {
clearInterval(t);
cb();
}
}, 60);
}
function deleteWord(cb) {
let text = el.textContent;
const t = setInterval(() => {
text = text.slice(0, -1);
el.textContent = text;
if (text.length === 0) {
clearInterval(t);
cb();
}
}, 35);
}
Deleting runs almost twice as fast as typing, 35ms per character against 60ms. That asymmetry is what makes it read as a terminal instead of just the typewriter effect in reverse.
Experimental: the ones that don’t fit a category because they’re doing something structurally different
Variable Weight Breathing
This animates a font’s weight axis on a loop instead of on cursor proximity:
@keyframes wghtBreathe {
0%,
100% {
font-variation-settings: "wght" 300, "opsz" 20;
}
50% {
font-variation-settings: "wght" 900, "opsz" 100;
}
}
Note the opsz (optical size) axis moving alongside weight. Fonts with a real optical size axis subtly reshape their letterforms at large sizes, not just thicken them, so animating both together looks like the letters are actually being redrawn rather than just gaining a bold filter.
Matrix Formation
This is the one specimen built on canvas instead of DOM spans, because it needs to render a whole field of falling, changing characters behind a target word:
for (let c = 0; c < cols; c++) {
const target = locked[c];
if (target && drops[c] >= target.row) {
ctx.fillStyle = "#C7A03D";
ctx.fillText(target.char, x, target.row * fontSize);
} else {
const ch = CHARS[randInt(0, CHARS.length - 1)];
ctx.fillText(ch, x, drops[c] * fontSize);
drops[c]++;
}
}
Only certain columns get a locked target letter assigned; the rest just rain random characters forever. The word "forms" because those specific columns stop scrambling once their falling counter reaches the target row, while every other column keeps going. It's the only animation in the set where DOM manipulation would have been the wrong tool entirely, drawing and erasing dozens of characters sixty times a second is exactly what canvas is for.
Word Morph
This cross-fades between two full words stacked in the same position:
.morph-wrap .demo.b {
position: absolute;
left: 0;
top: 0;
opacity: 0;
filter: blur(6px);
}
.morph-wrap.swap .demo.a {
opacity: 0;
filter: blur(6px);
}
.morph-wrap.swap .demo.b {
opacity: 1;
filter: blur(0);
}
Both words occupy the same coordinates via position: absolute, so the swap reads as one word transforming into another rather than two separate words taking turns.
Chromatic Scroll
This ties a red/cyan channel split to how far a section sits from the vertical center of the viewport:
const offset = Math.max(-1, Math.min(1, (stageCenter - center) / (vh / 2)));
const mag = Math.abs(offset) * 8;
el.style.textShadow = `${offset * mag}px 0 rgba(255,59,107,.7), ${
-offset * mag
}px 0 rgba(59,214,255,.7)`;
The two shadow offsets are exact negatives of each other, which is what keeps the split symmetric around the true letterforms instead of just shifting the whole word sideways.
Gravity Drop
This reuses the same staggered-span helper from the reveal group, but with a keyframe animation that overshoots before settling:
@keyframes gravityDrop {
0% {
opacity: 0;
transform: translateY(-160px);
}
60% {
opacity: 1;
transform: translateY(8px);
}
80% {
transform: translateY(-4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
The overshoot at 60% and the small bounce back at 80% are what sell the idea of weight. A letter that falls and stops dead at 0 looks like it faded in from above. A letter that falls past zero and settles back looks like it actually landed.
What twenty-nine examples taught me about three or four techniques
Strip away the surface variety, and this whole set really only rests on a handful of underlying mechanisms: splitting text into per-character spans and staggering a CSS transition across them, clipping a gradient to text for anything involving color movement, reading and writing custom properties as the bridge between JavaScript’s math and CSS’s rendering, and canvas for the one case where you’re not really animating text at all but drawing it fresh every frame.
The variety comes from combining those four ideas differently, not from needing thirty different technical approaches. Scramble and split-flap both interval-scramble characters; the only real difference is that split-flap staggers its per-letter timing and scramble doesn’t. Wave and liquid wobble both loop a transform on the same span-index delay; one moves the letter, the other skews it. Once you see the shared mechanism underneath, twenty-nine animations stop looking like twenty-nine things to learn and start looking like four things to combine.
Did you learn something good today as a developer?
Then show some love.
© Muhammad Usman
WordPress Developer | Website Strategist | SEO Specialist
Don’t forget to subscribe to Developer’s Journey to show your support.






























Top comments (2)
Which animation did I miss?
Add your suggestions, and I'll add them in next part.
What type of animations do you want next?
I'll make sure to cover it in my next piece of writing.
🥰