---
name: wave-text-animation
description: Use when you want "Playful, casual, friendly" - Moves letters up and down in a wave.
---

# Wave Text Animation

> **Category:** Text  -  **Personality:** Playful, casual, friendly
>
> **Best use:** Kids, playful brands, loaders
>
> **Ratings:** Professional 2/5 - Casual 5/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 2/5 - Performance cost 2/5

## What it is

Wave Text Animation splits a word into one `<span>` per letter and runs the **same** gentle up-and-down keyframe on every letter — but each letter's `animation-delay` is a little longer than its neighbour's. Because every letter is slightly further along in its cycle, the row reads as a single sine wave travelling across the word, looping forever. It is pure CSS motion (JavaScript only does the one-time letter split), so it is cheap and buttery-smooth. Unlike a one-shot "Split Text" reveal that settles into place, this **never stops** — which is exactly why it suits playful brand wordmarks, kids' UI, and "loading…" indicators.

## Dependencies / CDN

None for the effect — pure CSS animation plus a few lines of vanilla JS to wrap each character in a span. The demo's display/body fonts are optional:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Grandstander:wght@600;700;800&family=Quicksand:wght@400;500;600;700&display=swap" rel="stylesheet">
```

## HTML

Author plain text — the script turns it into letters. If JS never runs you simply get static, readable text (graceful fallback).

```html
<!-- A headline / wordmark -->
<h2 class="wt-wave">Time to wiggle!</h2>

<!-- The very same recipe doubles as a loader -->
<span class="wt-wave">Loading the fun</span>
```

## CSS

```css
/* every letter is inline-block so it can be nudged vertically */
.wt-l{
  display:inline-block;
  animation: wt-bob 2.1s ease-in-out infinite;
  animation-delay: calc(var(--i) * .085s);   /* <-- the per-letter stagger IS the wave */
}

/* inner span holds the glyph; lets a hover pop compose with the bob */
.wt-l > span{
  display:inline-block;
  text-shadow:0 4px 0 rgba(58,47,74,.10);                    /* soft sticker depth */
  transition: transform .2s cubic-bezier(.34,1.56,.64,1);    /* springy */
}
.wt-l:hover > span{ transform: scale(1.3); }

@keyframes wt-bob{
  0%,100%{ transform: translateY(.26em); }   /* em units => amplitude scales with font-size */
  50%    { transform: translateY(-.26em); }
}

/* optional: cohesive per-letter rainbow */
.wt-wave .wt-l:nth-child(6n+1) > span{ color:#ff4d8d }
.wt-wave .wt-l:nth-child(6n+2) > span{ color:#ff8a3d }
.wt-wave .wt-l:nth-child(6n+3) > span{ color:#f5a623 }
.wt-wave .wt-l:nth-child(6n+4) > span{ color:#27b894 }
.wt-wave .wt-l:nth-child(6n+5) > span{ color:#2f8fff }
.wt-wave .wt-l:nth-child(6n)   > span{ color:#8b5cf6 }

/* respect users who asked for less motion: letters just rest */
@media (prefers-reduced-motion: reduce){
  .wt-l{ animation:none; transform:none; }
}
```

## JavaScript

```js
/* Wrap each character of every .wt-wave element in spans:
   outer .wt-l carries the bob + its --i index, inner <span> holds the glyph. */
Array.prototype.forEach.call(document.querySelectorAll('.wt-wave'), function(el){
  var text = el.textContent;
  el.textContent = '';
  el.setAttribute('aria-label', text);            // SR reads the word, not the letters
  Array.prototype.forEach.call(text, function(ch, i){
    var outer = document.createElement('span');
    outer.className = 'wt-l';
    outer.setAttribute('aria-hidden', 'true');
    outer.style.setProperty('--i', i);            // index -> staggered animation-delay
    if(ch === ' '){
      outer.innerHTML = '&nbsp;';                 // keep the gap (and the wave's continuity)
    } else {
      var inner = document.createElement('span');
      inner.textContent = ch;
      outer.appendChild(inner);
    }
    el.appendChild(outer);
  });
});
```

The demo adds one extra, **optional** flourish (not part of the effect): a `pointermove` listener that drifts the decorative background a few pixels toward the cursor, skipped when `prefers-reduced-motion` is set.

## How it works

- **One keyframe, many delays.** Every letter runs the identical `wt-bob` (down → up → down). The only thing that differs between letters is `animation-delay`, fed by a per-letter `--i` index via `calc(var(--i) * .085s)`. That phase offset is what turns a row of identical bobs into a travelling sine wave.
- **`em`-based amplitude.** `translateY(.26em)` is relative to font-size, so the wave keeps the same proportions whether the headline renders at 42px on a phone or 92px on a desktop.
- **Spaces are kept as their own indexed span** (`&nbsp;`), so the wave keeps marching across word gaps instead of jumping.
- **It loops (`infinite`).** That continuous motion is the whole point and the thing that distinguishes it from a one-time Split-Text entrance animation.
- **Nested inner span** lets a hover `scale()` live on the child while the bob `translateY()` lives on the parent — two transforms that would otherwise fight for one `transform` property now compose cleanly.

## Customization

- **Speed / period:** change the animation `duration` (lower = more frantic, higher = lazy swell).
- **Wavelength:** change the delay step (`.085s`). A bigger step packs more visible crests into the word; a smaller step makes one long gentle swell.
- **Amplitude:** the `.26em` in the keyframe (try `.18em` subtle … `.4em` bouncy).
- **Feel:** swap the easing — `ease-in-out` is sine-like; a `cubic-bezier` with overshoot reads as a cartoon bounce.
- **Direction:** negate the delay (`calc(var(--i) * -.085s)`) to send the wave the other way.
- **Colour:** drop the `:nth-child` rainbow for a single brand colour, or animate `hue-rotate` on the parent for a shifting candy effect.

## Accessibility & performance

- Only `transform` (and optionally `color`) is animated → GPU-friendly, no layout or paint thrash. Cost stays trivial even for a long string.
- `aria-label` on the container plus `aria-hidden` on the letter spans means the word is announced **once**, cleanly, instead of letter-by-letter.
- Honour `prefers-reduced-motion`: the CSS sets `animation:none` so the letters sit still (this catalog also has a global reduced-motion guard as a backstop).
- Keep it to short strings — headlines, wordmarks, button labels, "loading…". Waving a whole paragraph wrecks readability.
- No-JS fallback is automatic: the markup begins life as ordinary text, so if the script fails the user still sees fully-readable static copy.

## Gotchas

- **Letters must be `display:inline-block`.** `transform` does nothing on a default inline element, so the wave silently won't move.
- **Don't leave a text-node space between generated spans.** White-space collapsing will swallow or duplicate gaps and desync your indices — create an explicit `&nbsp;` span instead (as the JS does).
- **One `transform` per element.** To add the hover pop you must scale a *nested* inner span; stacking a second transform-driven animation on the same letter just overrides the bob.
- **Use `em`, not `px`, for the lift** — a `px` amplitude won't track a responsive `clamp()` font-size and will look wrong at one end of the range.
- **Plain per-character loops split emoji (surrogate pairs) incorrectly.** Keep wave text to normal letters/punctuation; decorate with emoji elsewhere.
