# Animated Text Stays Readable

**MUST** · **ID:** `content-animated-text-stays-readable` · **Category:** content
**Source:** Custom
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/content-animated-text-stays-readable

> MUST: When splitting text into per-letter/word spans for animation, keep the accessible name on the wrapper (aria-label with the full text) and mark the shards aria-hidden="true". Otherwise a screen reader reads the word letter by letter or drops it. Drop the reveal to a fade under prefers-reduced-motion.

Splitting text into per-letter or per-word spans for animation must not destroy its accessible name

Staggered letter/word reveals are built by wrapping each glyph in its own element. Visually it is still a word; to a screen reader it is ten unrelated characters read "A, n, n, o…", or dropped entirely — and the same fragmentation breaks find-in-page, translation, and text selection. The fix costs nothing: put the real text as an aria-label on the wrapper and mark every animated shard aria-hidden="true", so the DOM animates while the accessibility tree sees one intact word. Pair it with reduced-motion — under prefers-reduced-motion the reveal should drop to a plain fade or appear instantly. This is WCAG 1.3.1 (Info and Relationships) at the glyph level: the visual grouping into a word must survive into the semantics.

## Rule snippet

```tsx
<span aria-label="Announcing">
  {letters.map(c => <span aria-hidden="true">{c}</span>)}
</span>
```

## Bad — do not do this

`content-animated-text-stays-readable-bad`

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

export function AnimatedTextStaysReadableBad() {
  const [play, setPlay] = useState(true);
  const word = 'Announcing';
  const replay = () => {
    setPlay(false);
    requestAnimationFrame(() => setPlay(true));
  };
  return (
    <div className="w-full max-w-sm py-6">
      <button
        onClick={replay}
        className="mb-3 rounded bg-muted px-2 py-1 text-xs text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        Replay
      </button>
      {/* Each letter is its own span with no accessible name on the word. */}
      <p className="text-2xl font-semibold text-foreground">
        {word.split('').map((ch, i) => (
          <span
            key={i}
            className="inline-block motion-safe:transition-all motion-safe:duration-500"
            style={{
              opacity: play ? 1 : 0,
              transform: play ? 'translateY(0)' : 'translateY(8px)',
              transitionDelay: `${i * 45}ms`,
            }}
          >
            {ch}
          </span>
        ))}
      </p>
      <p className="mt-3 text-xs text-muted-foreground">
        Screen reader gets:{' '}
        <span className="font-mono text-destructive">{word.split('').join(' · ')}</span>
      </p>
      <p className="mt-2 text-xs text-destructive">
        Ten bare spans, no wrapper name: with per-letter animation the word can be read as fragments — or dropped.
      </p>
    </div>
  );
}
```

## Good — do this

`content-animated-text-stays-readable-good`

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

export function AnimatedTextStaysReadableGood() {
  const [play, setPlay] = useState(true);
  const word = 'Announcing';
  const replay = () => {
    setPlay(false);
    requestAnimationFrame(() => setPlay(true));
  };
  return (
    <div className="w-full max-w-sm py-6">
      <button
        onClick={replay}
        className="mb-3 rounded bg-muted px-2 py-1 text-xs text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        Replay
      </button>
      {/* The wrapper carries the real word; the animated shards are hidden from AT. */}
      <p className="text-2xl font-semibold text-foreground">
        <span aria-label={word}>
          {word.split('').map((ch, i) => (
            <span
              key={i}
              aria-hidden="true"
              className="inline-block motion-safe:transition-all motion-safe:duration-500"
              style={{
                opacity: play ? 1 : 0,
                transform: play ? 'translateY(0)' : 'translateY(8px)',
                transitionDelay: `${i * 45}ms`,
              }}
            >
              {ch}
            </span>
          ))}
        </span>
      </p>
      <p className="mt-3 text-xs text-muted-foreground">
        Screen reader gets:{' '}
        <span className="font-mono text-success">{word}</span>
      </p>
      <p className="mt-2 text-xs text-success">
        aria-label carries “Announcing” as one word; the per-letter shards are aria-hidden. Same animation, intact semantics.
      </p>
    </div>
  );
}
```

## References

- [WCAG 1.3.1 — Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)
- [MDN — aria-label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label)
