DEV Community

Cover image for Copy These 29 Text Animations. The Code Is Already Done.
Muhammad Usman
Muhammad Usman

Posted on • Originally published at blog.stackademic.com

Copy These 29 Text Animations. The Code Is Already Done.

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode


.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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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%);
  }
}
Enter fullscreen mode Exit fullscreen mode

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)";
  });
}
Enter fullscreen mode Exit fullscreen mode

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)`;
  });
});
Enter fullscreen mode Exit fullscreen mode

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}`;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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) + "%");
}
Enter fullscreen mode Exit fullscreen mode
.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;
}
Enter fullscreen mode Exit fullscreen mode

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)`;
  });
});
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode
.stage[data-anim="shatter"]:hover .ch {
  transform: translate(var(--tx, 0), var(--ty, 0)) rotate(var(--tr, 0deg));
  opacity: 0;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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]++;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)`;
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
Developer's Journey

Top comments (2)

Collapse
 
web_dev-usman profile image
Muhammad Usman

Which animation did I miss?
Add your suggestions, and I'll add them in next part.

Collapse
 
web_dev-usman profile image
Muhammad Usman

What type of animations do you want next?
I'll make sure to cover it in my next piece of writing.
🥰