# Split Text by Length, Not by Habit

**MUST** · **ID:** `animations-text-split-granularity` · **Category:** animations
**Source:** [animate-text](https://pixelpoint.io/skills/animate-text/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-text-split-granularity

> MUST: Choose the split unit from the string length, not by reflex: per-character only under ~40 chars (22–46ms stagger), per-word beyond that (70–95ms), per-line for paragraphs (90–120ms). Total reveal = stagger × unit count, so the count is the variable that matters. Do not fix a long per-character cascade by clamping the delay — that collapses the tail into a flash. Preserve spaces: a gap that ends up inside an inline-block shard is collapsed away, so set white-space: pre-wrap on the wrapper or emit the space as a text node between tokens.

Pick per-character, per-word, or per-line from the string length — a per-letter cascade on a long headline is still arriving after the reader has finished it

The default move — split on `''`, map, multiply the index by a delay — is correct for "Hello" and wrong for a sentence, because total reveal time is stagger × unit COUNT, and only the count changes. Pixel Point's catalog prices this by unit: per-character effects run a 22–46ms stagger, per-word 70–95ms, per-line 90–120ms. The stagger goes UP as the unit gets bigger precisely because there are fewer of them, so every family lands in roughly the same total. Run soft-blur-in's 25ms across a 62-character headline and the last glyph does not begin until 1550ms, then takes its own 900ms to finish: 2.4 seconds of a title assembling itself while the reader has already read it and scrolled. Split the same string per word — eleven units at 70ms — and the cascade completes in 770ms with its rhythm intact. IMPORTANT — this is NOT animations-lottie-stagger-budget in different words. That rule clamps the delay (`delay = Math.min(i * step, MAX)`), which is the right fix for a list of unknown length; applied to text it is the WRONG fix, because clamping collapses the tail of the cascade so the last fourteen letters all fire on the same frame — a flash glued to the end of a stagger. For text you change the unit, not the ceiling. Whichever unit you pick, spaces must survive the split, and neither of the obvious ways works by default: a space that becomes its own `inline-block` shard, or that trails inside a word token, is collapsed away and the headline silently loses its word gaps. Either set `white-space: pre-wrap` on the wrapper, or emit the gap as a text node BETWEEN the animated tokens.

## Rule snippet

```tsx
// bad — 45 chars x 60ms = last letter starts at 2.6s
text.split("").map((c, i) => <span style={{ animationDelay: `${i * 60}ms` }}>{c}</span>)
// good — 8 words x 70ms, cascade intact, done in 1.2s, gaps outside the shards
text.split(" ").map((w, i, all) => (
  <Fragment key={i}>
    <span className="inline-block" style={{ animationDelay: `${i * 70}ms` }}>{w}</span>
    {i < all.length - 1 ? " " : null}
  </Fragment>
))
```

## Bad — do not do this

`animations-text-split-granularity-bad`

```tsx
import { useState } from 'react';

const HEADLINE = 'Design systems that scale with your whole team';

// The reflex: split on '', multiply the index by a delay, ship it.
const CHARS = HEADLINE.split('');
const STAGGER_MS = 60;
const DURATION_MS = 700;
const TOTAL_MS = (CHARS.length - 1) * STAGGER_MS + DURATION_MS;

export function TextSplitGranularityBad() {
  const [run, setRun] = useState(0);

  return (
    <div className="space-y-4">
      <button
        onClick={() => setRun((v) => v + 1)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Replay reveal
      </button>

      <div className="min-h-[7rem] rounded-lg bg-muted p-4">
        <h3 key={run} className="text-2xl font-medium leading-snug whitespace-pre-wrap">
          {CHARS.map((char, i) => (
            <span
              key={i}
              className="inline-block"
              style={{
                animation: `splitBadIn ${DURATION_MS}ms cubic-bezier(0.68, -0.55, 0.27, 1.55) both`,
                animationDelay: `${i * STAGGER_MS}ms`,
              }}
            >
              {char}
            </span>
          ))}
        </h3>
      </div>

      <style>{`
        @keyframes splitBadIn {
          from { opacity: 0; transform: translateY(24px) scale(0.8); }
          to { opacity: 1; transform: translateY(0) scale(1); }
        }
      `}</style>

      <p className="text-xs text-destructive">
        {CHARS.length} characters × {STAGGER_MS}ms = the last letter starts at{' '}
        {(CHARS.length - 1) * STAGGER_MS}ms and the headline is still assembling{' '}
        {(TOTAL_MS / 1000).toFixed(1)}s in — long after it has been read
      </p>
    </div>
  );
}
```

## Good — do this

`animations-text-split-granularity-good`

```tsx
import { Fragment, useState } from 'react';

const HEADLINE = 'Design systems that scale with your whole team';

// 45 characters is past the ~40 mark where a per-letter cascade stops being a
// reveal, so the unit becomes the word. Fewer units means the per-unit stagger
// goes UP (70ms, not 25ms) and the whole thing still lands under a second.
const WORDS = HEADLINE.split(' ');
const STAGGER_MS = 70;
const DURATION_MS = 700;
const TOTAL_MS = (WORDS.length - 1) * STAGGER_MS + DURATION_MS;

export function TextSplitGranularityGood() {
  const [run, setRun] = useState(0);

  return (
    <div className="space-y-4">
      <button
        onClick={() => setRun((v) => v + 1)}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90"
      >
        Replay reveal
      </button>

      <div className="min-h-[7rem] rounded-lg bg-muted p-4">
        {/* The split shards are decoration; the accessibility tree gets one string. */}
        <h3 key={run} className="text-2xl font-medium leading-snug">
          <span className="sr-only">{HEADLINE}</span>
          {WORDS.map((word, i) => (
            // The gap is a text node BETWEEN the tokens, not inside one: a trailing
            // space at the end of an inline-block is collapsed away, and the headline
            // silently loses its word spacing.
            <Fragment key={i}>
              <span
                aria-hidden="true"
                className="split-good-word inline-block"
                style={{ animationDelay: `${i * STAGGER_MS}ms` }}
              >
                {word}
              </span>
              {i < WORDS.length - 1 ? ' ' : null}
            </Fragment>
          ))}
        </h3>
      </div>

      <style>{`
        .split-good-word {
          animation: splitGoodIn ${DURATION_MS}ms cubic-bezier(0.16, 1, 0.3, 1) both;
        }
        @keyframes splitGoodIn {
          from { opacity: 0; transform: translateY(12px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .split-good-word { animation: none; }
        }
      `}</style>

      <p className="text-xs text-success">
        {WORDS.length} words × {STAGGER_MS}ms — the cascade keeps its rhythm and completes in{' '}
        {(TOTAL_MS / 1000).toFixed(2)}s, while the reader still gets one intact heading
      </p>
    </div>
  );
}
```

## References

- [animate-text — soft-blur-in spec (usage_notes)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/assets/specs/soft-blur-in.json)
- [animate-text — per-word / per-line staggers (catalog)](https://github.com/pixel-point/animate-text/blob/main/skills/animate-text/references/catalog.md)
- [MDN — animation-delay](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation-delay)
