---
name: split-text-animation
description: Use when you want "Premium, editorial, polished" - Animates words, lines, or letters separately.
---

# Split Text Animation

> **Category:** Text  -  **Personality:** Premium, editorial, polished
>
> **Best use:** Hero titles, landing pages
>
> **Ratings:** Professional 5/5 - Casual 3/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 4/5 - Performance cost 2/5

## What it is

Split-text animation breaks a headline into individual words and letters, then animates each piece independently. Here every glyph **rises and rotates up out of a clip mask** on a tiny per-letter delay, producing a cascading "typeset in motion" reveal. It is the signature move of premium editorial and landing-page heroes because it leads the eye through the words in reading order and feels crafted rather than canned. The splitting is done in a few lines of **vanilla JS** (each character wrapped in a `<span>`); the motion and the stagger are **pure CSS transitions** — no animation library required.

## Dependencies / CDN

**None - vanilla JS + CSS.** The demo loads two display/body fonts for the editorial look (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=Manrope:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,500;0,600;0,700;0,800;1,500;1,600&display=swap" rel="stylesheet">
```

## HTML

The real text lives in the markup (so it stays accessible and works with JS off). `data-split` flags the element to split; `<em>` marks an emphasis word.

```html
<section id="hero">
  <h1 class="head" data-split>Quiet luxury, set in <em>motion.</em></h1>
  <button id="replay" type="button" aria-label="Replay the headline animation">&#8635; Replay</button>
</section>
```

## CSS

```css
/* Each WORD is a clip mask. Padding + equal negative margin enlarge the clip box
   (so descenders / italics aren't cut) without changing layout. */
.word{display:inline-block; overflow:hidden; vertical-align:top;
  padding:.12em .14em .22em; margin:-.12em -.14em -.22em}
.word--em{font-style:italic; color:#dcb472}      /* emphasis word */

/* Each CHAR carries its stagger via a per-glyph custom property --i. */
.char{display:inline-block; will-change:transform,opacity;
  transition:transform .92s cubic-bezier(.16,1,.3,1), opacity .6s ease;
  transition-delay:calc(var(--i,0) * .03s)}

/* Hidden start state (applied by JS via .ready) -> revealed via .is-in */
.ready .char{opacity:0; transform:translateY(118%) rotate(6deg)}
.is-in .char{opacity:1; transform:translateY(0) rotate(0)}

/* During a replay reset we kill transitions so nothing animates backwards. */
.instant *{transition:none!important}

@media (prefers-reduced-motion:reduce){
  .char{transition:none!important}
  .ready .char{opacity:1!important; transform:none!important}   /* show instantly */
}
```

## JavaScript

```js
const root   = document.getElementById('hero');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
let idx = 0;

function buildWord(text, em){
  const w = document.createElement('span');
  w.className = 'word' + (em ? ' word--em' : '');
  for (const ch of text){
    const c = document.createElement('span');
    c.className = 'char';
    c.style.setProperty('--i', idx++);   // per-glyph stagger index
    c.textContent = ch;
    w.appendChild(c);
  }
  return w;
}

// text nodes -> words; <em> -> emphasis words; whitespace kept so words still wrap
function split(head){
  const nodes = [...head.childNodes];
  head.textContent = '';
  for (const node of nodes){
    const em = node.nodeType === 1 && node.tagName === 'EM';
    node.textContent.split(/(\s+)/).forEach(tok => {
      if (!tok) return;
      if (/^\s+$/.test(tok)) head.appendChild(document.createTextNode(' '));
      else head.appendChild(buildWord(tok, em));
    });
  }
}
document.querySelectorAll('[data-split]').forEach(split);

// Replay: disable transitions -> reset to hidden -> reflow -> re-enable -> re-add .is-in
function play(){
  root.classList.add('instant');
  root.classList.remove('is-in');
  void root.offsetWidth;                 // flush the reset
  root.classList.remove('instant');
  void root.offsetWidth;
  root.classList.add('is-in');
}

root.classList.add('instant', 'ready');  // apply hidden start without a flash
void root.offsetWidth;
root.classList.remove('instant');
reduce ? root.classList.add('is-in')      // reduced motion: just show it
       : requestAnimationFrame(() => requestAnimationFrame(play));

document.getElementById('replay')
  .addEventListener('click', () => reduce ? root.classList.add('is-in') : play());
```

## How it works

- **Split in JS, animate in CSS.** JS walks the heading's child nodes, wrapping each character in `<span class="char">` and giving it a CSS variable `--i` equal to its global index. Everything else is CSS.
- **The clip mask is the word.** Each word is `overflow:hidden`. A char starts at `translateY(118%)` (pushed fully *below* the mask) so it is invisible; revealing it transitions the transform back to `0`, and the glyph **slides up into the mask** — the "rise / clip" look. A small `rotate` adds editorial character.
- **The stagger is free.** `transition-delay: calc(var(--i) * .03s)` means glyph 0 starts immediately, glyph 1 at 30ms, glyph 2 at 60ms... Adding `.is-in` fires *all* transitions in the same frame; the browser schedules each one. No `setTimeout`, no per-element JS.
- **Replay needs a reset trick.** Simply removing `.is-in` would play the transition in reverse (glyphs sink back down). Instead `.instant` switches `transition:none`, we snap to the hidden state, force a reflow, drop `.instant`, then re-add `.is-in` so it animates forward again.

## Customization

- **Pace:** the `.03s` multiplier controls the cascade — `.02s` snaps, `.05s` is slow and luxurious.
- **Granularity:** for per-*word* (not per-letter) motion, index by word instead of char, or split only on spaces. For long body copy, split by word or line to keep node counts low.
- **Motion flavour:** swap the start transform — `translateY` alone (clean rise), add `rotate`/`skew` (editorial), or use `clip-path`/`filter:blur()`. Tune the feel with the `cubic-bezier`.
- **Direction:** reveal right-to-left with `calc((TOTAL - var(--i)) * .03s)` (pass the total count to a variable).
- **Emphasis beat:** `.word--em` gets italic + an accent colour here; give it a different delay or transform to make one word the focal point.

## Accessibility & performance

- **Text stays real.** The heading is plain text in the HTML; JS only wraps it, so it remains selectable, copyable and read in order by screen readers (spaces are preserved as real text nodes, so words don't run together). If per-char spans ever cause odd announcements, add an `aria-label` with the full string on the heading.
- **Cheap motion.** Only `transform` and `opacity` animate — compositor-only, no layout or paint thrash. `will-change` hints the chars.
- **`prefers-reduced-motion: reduce`** shows the text instantly: CSS forces the chars visible with `transition:none`, and JS skips the staggered `play()`. With JS disabled entirely, the untouched headline renders normally (progressive enhancement).

## Gotchas

- **Clipped descenders / italics.** An `overflow:hidden` mask cuts glyph tails (g, y, j) and italic overhangs. Fix: pad the mask and apply an equal *negative* margin — the clip box grows but layout is unchanged (the `padding`/`margin` pair on `.word`).
- **Disappearing spaces.** Wrapping every word in `inline-block` collapses the whitespace between words. Re-insert real space text nodes (as above) or the headline becomes one unbroken string.
- **Reverse-animation on replay.** Don't just toggle `.is-in` off/on; use the `.instant` reset so glyphs don't visibly sink before rising again.
- **Flash of unsplit text (FOUC).** Run the split in an inline script at the end of `<body>` and apply the hidden state under `.instant`, so users don't see the fully-formed headline blink before it animates.
- **CSS variable units.** `style.setProperty('--i', n)` stores a *unitless* number; supply the unit at use-time with `calc(var(--i) * .03s)`. Passing `'5px'` here would break the `calc`.
