EmpireUI
Get Pro
← Blog8 min read#css-animation#text-effects#react

CSS Text Animation Effects: 15 Techniques from Typewriter to Glitch

15 CSS text animation techniques — typewriter, glitch, gradient shimmer, wave, neon glow and more. Real code, React-ready, with Tailwind v4 examples for developers.

Code editor showing CSS animation keyframes for text effects on a dark background

Why CSS Text Animations Are Still Worth Learning in 2026

Honestly, JavaScript animation libraries have made developers lazy about CSS. And that's a shame. Pure CSS text animations are performant, dependency-free, and — when done right — genuinely beautiful without adding a single kilobyte to your JS bundle.

Text is everywhere. It's the primary medium of the web. So it stands to reason that animating text well is one of the highest-leverage things you can do for visual impact. A hero heading that types itself in, a glitching product name, a gradient shimmer on a loading state — these are the details users actually notice.

This article covers 15 concrete techniques. Some are one-liners. Others need a keyframe block and some CSS variables. All of them are production-ready and React/Tailwind compatible. We're skipping the theory and going straight to the code you can copy.

Worth noting: if you want animated backgrounds underneath your text effects, check out aurora background for React or particles background — combining both levels of animation creates genuinely striking results.

The Typewriter Effect: Three Different Approaches

The typewriter is the most-requested text animation. There are at least three ways to build it — pure CSS, CSS + JS, and React hook — and each has trade-offs.

The pure CSS approach uses width animation on a monospace element with overflow: hidden and white-space: nowrap. It's elegant for fixed strings but brittle for dynamic content because you have to hardcode the ch value to match the string length exactly.

Here's the cleanest pure-CSS version: ``css .typewriter { font-family: 'Courier New', monospace; overflow: hidden; white-space: nowrap; border-right: 3px solid currentColor; width: 0; animation: typing 2.8s steps(30, end) forwards, blink-caret 0.75s step-end infinite; } @keyframes typing { from { width: 0; } to { width: 30ch; } } @keyframes blink-caret { from, to { border-color: transparent; } 50% { border-color: currentColor; } } ` The steps() function is the key. Without it you get a smooth slide — with steps(30, end)` you get discrete character-by-character movement that actually looks like typing.

For React with dynamic strings, a small useTypewriter hook that updates state on a setInterval is far more reliable. Pass speed: 50 for a 50ms-per-character pace and you'll get natural-feeling output. Set it to 28 if you want it to feel urgent.

Gradient Text and Shimmer Animations

Gradient text is simple. You set a background: linear-gradient(...), apply background-clip: text, then set color: transparent. What most tutorials skip is how to *animate* that gradient.

The trick: you can't animate background-position on a gradient directly in older browsers. The modern approach is to make the gradient twice as wide (200% background-size) and animate background-position from 0% 50% to 100% 50%. This creates a smooth shimmer across the text.

// React component — gradient shimmer heading
const ShimmerText = ({ children }: { children: React.ReactNode }) => (
  <span
    className="inline-block bg-clip-text text-transparent"
    style={{
      backgroundImage: 'linear-gradient(90deg, #6366f1, #ec4899, #06b6d4, #6366f1)',
      backgroundSize: '200% auto',
      animation: 'shimmer 3s linear infinite',
    }}
  >
    {children}
  </span>
);

// In your global CSS or <style> tag:
// @keyframes shimmer {
//   to { background-position: 200% center; }
// }

In Tailwind v4.0.2 you can define this as a custom utility with @utility in your CSS. That's cleaner than inline styles and you get proper purging. Define the keyframe in your base layer and reference it from the utility class — one class, works everywhere.

Glitch Effect: CSS Channels and Clip-Path Splits

The glitch effect looks complex but it's really just two pseudo-elements offset slightly in red and blue, with erratic animation on clip-path. The visual "tearing" comes from clipping different horizontal slices of the text at different times.

Here's a minimal implementation that avoids filter: blur and box-shadow spam — those are expensive to composite.

.glitch {
  position: relative;
  color: white;
  font-size: 4rem;
  font-weight: 900;
}

.glitch::before,
.glitch::after {
  content: attr(data-text);
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.glitch::before {
  color: #ff0040;
  animation: glitch-1 0.4s infinite linear alternate-reverse;
  clip-path: polygon(0 0, 100% 0, 100% 35%, 0 35%);
  transform: translate(-4px, 0);
}

.glitch::after {
  color: #0040ff;
  animation: glitch-2 0.4s infinite linear alternate-reverse;
  clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%);
  transform: translate(4px, 0);
}

@keyframes glitch-1 {
  0%   { transform: translate(-4px, 2px); clip-path: polygon(0 0, 100% 0, 100% 40%, 0 40%); }
  33%  { transform: translate(4px, -2px); clip-path: polygon(0 10%, 100% 10%, 100% 50%, 0 50%); }
  66%  { transform: translate(-2px, 1px); clip-path: polygon(0 5%, 100% 5%, 100% 30%, 0 30%); }
  100% { transform: translate(3px, -1px); clip-path: polygon(0 0, 100% 0, 100% 45%, 0 45%); }
}

@keyframes glitch-2 {
  0%   { transform: translate(4px, -2px); clip-path: polygon(0 60%, 100% 60%, 100% 100%, 0 100%); }
  33%  { transform: translate(-4px, 2px); clip-path: polygon(0 70%, 100% 70%, 100% 100%, 0 100%); }
  66%  { transform: translate(2px, -1px); clip-path: polygon(0 55%, 100% 55%, 100% 85%, 0 85%); }
  100% { transform: translate(-3px, 1px); clip-path: polygon(0 65%, 100% 65%, 100% 100%, 0 100%); }
}

One gotcha: you need data-text on the element to match the text content, since ::before and ::after use attr(data-text) for their content. In React, that means <h1 className="glitch" data-text="EMPIRE">EMPIRE</h1>. It's slightly redundant but unavoidable in pure CSS.

Keep the animation duration between 0.3s and 0.6s. Any slower and it looks like lag. Any faster and it induces headaches. And please — add prefers-reduced-motion support. Some users get motion sick from rapid glitch animations.

Wave, Bounce, and Per-Character Stagger Animations

Per-character animations are the ones that look hardest to build but are actually quite systematic. The pattern: split text into <span> elements, one per character, then use animation-delay to stagger them. Can you do this in React without a library? Absolutely.

const WaveText = ({ text }: { text: string }) => (
  <span aria-label={text} role="text">
    {text.split('').map((char, i) => (
      <span
        key={i}
        aria-hidden="true"
        style={{
          display: 'inline-block',
          animation: 'wave 1.2s ease-in-out infinite',
          animationDelay: `${i * 0.08}s`,
        }}
      >
        {char === ' ' ? ' ' : char}
      </span>
    ))}
  </span>
);

// CSS:
// @keyframes wave {
//   0%, 100% { transform: translateY(0); }
//   50%       { transform: translateY(-12px); }
// }

Note the   for spaces — regular spaces collapse in inline-block context. The aria-label on the parent and aria-hidden on each span keeps screen readers happy. Don't skip accessibility here; it's not optional.

The 0.08s delay per character is a good starting point. For long text (10+ characters), dial it back to 0.05s or the end of the animation lags too far behind the start. For short words like a logo, 0.12s gives a more dramatic cascade.

Neon Glow, Shadow Pulse, and Stroke Effects

Neon glow is text-shadow layered. Four to six shadow values at increasing blur radii, all the same hue, creates that tube-light look. One shadow isn't enough — you need the inner bright core AND the outer diffuse halo.

.neon-text {
  color: #fff;
  text-shadow:
    0 0 7px  #fff,
    0 0 10px #fff,
    0 0 21px #fff,
    0 0 42px #0fa,
    0 0 82px #0fa,
    0 0 92px #0fa,
    0 0 102px #0fa,
    0 0 151px #0fa;
  animation: flicker 1.5s infinite alternate;
}

@keyframes flicker {
  0%, 18%, 22%, 25%, 53%, 57%, 100% {
    text-shadow:
      0 0 7px #fff,
      0 0 10px #fff,
      0 0 21px #fff,
      0 0 42px #0fa,
      0 0 82px #0fa,
      0 0 92px #0fa;
  }
  20%, 24%, 55% {
    text-shadow: none;
    color: rgba(255,255,255,0.15);
  }
}

The rgba(255,255,255,0.15) at the flicker frames is what makes it feel like a real failing neon tube instead of just a blinking light. The color doesn't go pure black — it dims to that near-invisible value first.

For dark-mode UIs following the glassmorphism design patterns, neon text works exceptionally well over frosted-glass card backgrounds. The glow bleeds into the blur in a way that looks expensive. If you're building a theme toggle, you'll probably want to swap out neon glow for a simpler text color in light mode — the effect clashes badly with white backgrounds.

Scramble, Reveal, and Clip-Path Text Transitions

Text scramble — where characters randomly cycle through letters before settling on the final word — is a classic. Pure CSS can't do it (you need JS to swap characters), but the animation itself can be CSS-driven once you have the DOM elements in place.

The reveal techniques, on the other hand, are pure CSS territory. The cleanest one uses clip-path: inset(0 100% 0 0) as a starting state and animates to clip-path: inset(0 0% 0 0). This creates a left-to-right wipe that feels like the text is being uncovered by a sliding mask. No opacity, no blur — just geometry.

.reveal-text {
  animation: text-reveal 0.8s cubic-bezier(0.77, 0, 0.175, 1) forwards;
  animation-delay: 0.2s;
  clip-path: inset(0 100% 0 0);
}

@keyframes text-reveal {
  to {
    clip-path: inset(0 0% 0 0);
  }
}

The cubic-bezier(0.77, 0, 0.175, 1) easing is deliberately aggressive — fast start, slight overshoot feel at the end. It reads as "snappy" rather than "floaty". Use ease-out if you want something gentler. One thing worth combining: stack this reveal with a background spotlight effect — the spotlight effect for React pairs naturally with text reveals for hero sections.

Performance, Accessibility, and Knowing When to Stop

Every animation on this page should be wrapped in @media (prefers-reduced-motion: reduce). That's not a suggestion. Some of these effects — glitch especially — can trigger vestibular disorders. The fix is three lines of CSS and there's no excuse for skipping it.

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

On the performance side: transform and opacity are GPU-composited and essentially free. text-shadow, filter, and clip-path are more expensive — not prohibitively so, but don't pile them all on the same element. Use Chrome DevTools' Layers panel to confirm you're not accidentally creating hundreds of compositor layers from a wave animation on a long string.

And finally — how much is too much? One animated text element per viewport scroll section is usually fine. Two is occasionally okay. Three or more animated text elements visible at once almost always looks chaotic. Text animation should direct attention, not compete for it. The most effective uses of these techniques are on single headline elements with everything else static around them.

FAQ

Can I use CSS text animations in Next.js without any extra packages?

Yes. All of the pure-CSS techniques here work in Next.js without additional packages. Define your @keyframes in a global CSS file imported in your layout, or use CSS Modules with :global for keyframe names. The React component examples only need standard React — no animation library required.

Why does my typewriter animation show the full text width before animating?

You probably have width set to something other than 0 in the initial state, or the element has a min-width. Make sure the element starts at width: 0 with overflow: hidden and white-space: nowrap. Also confirm you're using steps() — without it the width transitions smoothly and you see the full content box before the animation kicks off.

How do I make gradient text work in Safari?

Safari requires -webkit-background-clip: text and -webkit-text-fill-color: transparent instead of (or in addition to) the standard background-clip: text and color: transparent. Most modern autoprefixers handle this, but if you're writing inline styles in React, add both prefixed and unprefixed versions manually.

What's the best way to handle the glitch data-text attribute in React?

Pass it as a prop and use it for both the element content and the data-text attribute: <span className='glitch' data-text={text}>{text}</span>. If the text is dynamic and changes frequently, memoize the component with React.memo to avoid unnecessary re-renders triggering animation restarts.

Are these text animations compatible with Tailwind v4?

Yes. In Tailwind v4.0.2 you can define custom keyframes using @keyframes in your CSS alongside @theme or in a base layer, then reference them with arbitrary values like animate-[shimmer_3s_linear_infinite]. For reusable animations, define them as @utility classes in your CSS file. The arbitrary value syntax is slightly verbose but works fine.

How do I stop the per-character wave animation from causing layout shifts?

Use transform: translateY() for the wave movement, never top or margin. Transform animations don't trigger layout reflow. Also set display: inline-block on each character span — inline elements can't apply transform, so without this the animation silently does nothing and you'll waste an hour debugging it.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

CSS Keyframe Animation Library: 20 Copy-Paste EffectsCSS Animation Performance: GPU Layers, will-change, 60fpsTailwind Animation Library: 30 Classes for Common EffectsCSS Grid vs Flexbox: Not a Versus — A Complete When-to-Use Guide