# Split Text (Moonarc)

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view. One string to screen readers, zero script of its own.

- Import: `import SplitText from '@moonarc/core/SplitText'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/split-text.json`
- Tier A · category text · trigger scroll
- Readout: `<SplitText step={45}>`
- Browser support: widely (every browser); elsewhere: scrub uses animation-timeline: view() (Chrome 115, Safari 26); elsewhere every word is full
- Measured cost: 0 B JS of its own · uses Reveal + runtime (with dependencies 3.0 kB raw; CSS 8.4 kB raw)
- Page: https://moonarc.dev/components/split-text/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The text to split. Use \n for explicit lines with by="line". |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `by` | `'word' | 'char' | 'line'` | `'word'` | Split unit. |
| `step` | `number` | none | Stagger between parts in ms. Defaults: word 40, char 25, line 90. |
| `maxTotal` | `number` | `600` | Cap on the total stagger in ms; the step shrinks to fit long text. |
| `duration` | `number` | `500` | Per-part duration in ms. |
| `from` | `'bottom' | 'top' | 'none'` | `'bottom'` | Where parts travel from. |
| `distance` | `number` | `12` | Travel distance in px. |
| `blur` | `number` | none | Blur in px to sharpen from. |
| `delay` | `number` | `0` | Delay before the first part, ms. |
| `easing` | `string` | `preset curve` | Any CSS easing for each part. Unset, the preset's sampled spring. |
| `once` | `boolean` | `true` | Play once and stay. |
| `threshold` | `number` | `0.2` | Fraction visible to trigger. |
| `rootMargin` | `string` | `'0px 0px -10% 0px'` | IntersectionObserver rootMargin, passed to Reveal; the default waits until the text is 10% into the viewport. scrub ignores it. |
| `mask` | `boolean` | `false` | Each part is uncovered as it rises from behind a clip of its own line. from="top" drops it in instead. |
| `scrub` | `boolean` | `false` | Scroll-linked: words brighten from dim to full, in order, as the block moves through the viewport, on its own view() timeline. Where scroll timelines do not exist every word is full. |
| `dim` | `number` | `0.25` | Resting opacity of an unread word in scrub mode. |

## Usage

```astro
<SplitText as="h1" text="Split on the server, revealed on scroll." />
<SplitText by="char" text="letter by letter" step={25} />
<SplitText as="h2" text="Rises from behind its own line." mask />
<SplitText as="p" text="Words light up as you read down the page…" scrub />
```

## Reduced motion

Inherits Reveal: fast opacity fade, no travel, no stagger.

## With ClientRouter

Inherits Reveal: rebinds through the shared runtime after every navigation.

## Craft

- Split on the server so the HTML already contains every word; nothing is measured or re-flowed on the client.
- by="char" splits graphemes (Intl.Segmenter): an emoji with a skin tone or a letter with a combining accent is one part. Words and characters are inline-blocks, which bidi orders by the direction around them, so the element takes dir="auto" and Latin text on a right-to-left page still reads left to right.
- Stagger 40 ms per word, 25 per character, capped at 600 ms total: long text shrinks the step rather than lengthening the wait.
- The text once, in a visually hidden span (.ma-sr), and aria-hidden parts: one string for assistive tech, whatever element it renders as. Not aria-label on the wrapper: a span, div or p may not be named that way (ARIA 1.2), and screen readers skip it there.
- Composes Reveal instead of re-implementing the observer; costs nothing on a page that already has one.
- mask clips each part to its own line box (clip-path on an inline-block, which keeps the baseline where overflow would move it) and translates the part by 110% inside it: the word is uncovered from the baseline, the way type is set.
- scrub gives every word a slice of the block's cover range (20% to 60%, word n from 20% + n·k) on one named view timeline, so a paragraph reads itself in as you scroll and rewinds when you scroll back. No IntersectionObserver fallback on purpose: full words are the honest state where the timeline is missing.

## Replaces

- SplitText (React Bits)
- BlurText (React Bits)
- TextReveal (Magic UI)
- GSAP SplitText for entrances

## Source

```astro
---
/**
 * SplitText — split on the server, reveal on scroll, one string to a reader.
 *
 * Composes Reveal: the parts are direct children with a per-child stagger, so
 * it inherits once-and-stay, reduced motion and ClientRouter safety, and ships
 * no script of its own. A visually hidden copy (.ma-sr) + aria-hidden parts keep it
 * one string for assistive tech instead of a stream of letters.
 *
 * `mask` wraps every part in a clipped box it rises out of, so the text is
 * uncovered from its own baseline instead of fading up. `scrub` binds each
 * word's opacity to the block's view() timeline — words light up as you read
 * down the page — and is simply full where scroll timelines do not exist.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import Reveal from './Reveal.astro';

interface Props extends HTMLAttributes<'span'> {
  /** The text to split. Use `\n` for explicit lines in `by="line"`. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Split unit. */
  by?: 'word' | 'char' | 'line';
  /** Stagger between parts in ms. Defaults: word 40, char 25, line 90. */
  step?: number;
  /** Cap on the total stagger in ms; the step shrinks to fit long text. */
  maxTotal?: number;
  /** Per-part duration in ms. */
  duration?: number;
  /** Where parts travel from. */
  from?: 'bottom' | 'top' | 'none';
  /** Travel distance in px. */
  distance?: number;
  /** Blur in px to sharpen from. */
  blur?: number;
  /** Delay in ms before the first part. */
  delay?: number;
  /** Any CSS easing. */
  easing?: string;
  /** Play once and stay. */
  once?: boolean;
  /** Fraction visible to trigger. */
  threshold?: number;
  /** IntersectionObserver rootMargin. */
  rootMargin?: string;
  /** Each part rises from behind a clip of its own line instead of fading up. */
  mask?: boolean;
  /** Scroll-linked: words brighten from dim to full as the block moves through the viewport (animation-timeline: view()). Without scroll timelines every word is full. */
  scrub?: boolean;
  /** Resting opacity of an unread word in scrub mode, 0–1. */
  dim?: number;
}

const {
  text,
  as = 'span',
  by = 'word',
  step,
  maxTotal = 600,
  duration = 500,
  from = 'bottom',
  distance = 12,
  blur,
  delay,
  easing,
  once,
  threshold,
  rootMargin,
  mask = false,
  scrub = false,
  dim = 0.25,
  class: className,
  style,
  ...rest
} = Astro.props;

// a character is a grapheme, not a code point: an emoji with a skin tone or a letter with a combining accent is one part
const parts: string[] = by === 'char' ? Array.from(new Intl.Segmenter().segment(text), (g) => g.segment) : by === 'line' ? text.split('\n') : text.split(/(\s+)/).filter(Boolean);
const animated = parts.filter((p) => !/^\s+$/.test(p)).length;
const defaultStep = by === 'char' ? 25 : by === 'line' ? 90 : 40;
// Cap the total: forty words at 40 ms is 1.6 s, which is a wait, not an entrance.
const interval = Math.max(1, Math.min(step ?? defaultStep, animated > 1 ? maxTotal / (animated - 1) : maxTotal));
// scrub: each word owns a slice of the block's cover range, 20% → 60%, with a little overlap
const scrubVars = scrub ? `--ma-split-n:${animated};--ma-split-k:${(40 / Math.max(animated, 1)).toFixed(3)}%;--ma-split-dim:${dim}` : '';
const inline = [scrubVars, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
let index = 0;
// dir="auto" for words and characters: the parts are inline-blocks, which bidi orders by the page's direction, so Latin
// text on a right-to-left page came out back to front. Lines are blocks of plain text and keep the page's direction.
---

<Reveal
  as={as}
  stagger={scrub ? undefined : interval}
  duration={duration}
  from={mask || scrub ? 'none' : from}
  distance={distance}
  blur={mask || scrub ? undefined : blur}
  delay={delay}
  easing={easing}
  once={once}
  threshold={scrub ? 0 : threshold}
  rootMargin={scrub ? '0px 0px 0px 0px' : rootMargin}
  class={['ma-split', `ma-split--${by}`, mask && 'ma-split--mask', mask && from === 'top' && 'ma-split--top', scrub && 'ma-split--scrub', className].filter(Boolean).join(' ')}
  dir={by === 'line' ? undefined : 'auto'}
  style={inline || undefined}
  {...rest}
>
  {parts.map((part) =>
    /^\s+$/.test(part) ? (
      ' '
    ) : mask ? (
      <span class="ma-split__mask">
        <span class="ma-split__part" aria-hidden="true">{part}</span>
      </span>
    ) : (
      <span class="ma-split__part" aria-hidden="true" style={scrub ? `--ma-split-i:${index++}` : undefined}>{part}</span>
    ),
  )}
  <span class="ma-sr">{text}</span>
</Reveal>

<style is:global>
  @layer components {
    /* break-spaces, not pre: both keep a part's text exactly as written, but pre also forbade wrapping, so a word
       wider than its line ran out of it whatever overflow-wrap the host set; now overflow-wrap: anywhere breaks it */
    :where(.ma-split__part) {
      display: inline-block;
      white-space: break-spaces;
    }
    /* a line is the author's line, not the screen's: one longer than the column wraps inside its own block instead of
       running out of it (white-space: pre kept a two-word line from ever breaking on a phone) */
    :where(.ma-split--line > .ma-split__part),
    :where(.ma-split--line > .ma-split__mask),
    :where(.ma-split--line > .ma-split__mask > .ma-split__part) {
      display: block;
      white-space: pre-wrap;
    }
    /* mask: the wrapper is Reveal's staggered child (it only fades, from="none"); the part rises inside its clip */
    :where(.ma-split__mask) {
      display: inline-block;
      clip-path: inset(0 0 0 0);
      white-space: break-spaces;
    }
    /* the rise keeps the timing of the wrapper's fade: Reveal's delay on the root and its index on the wrapper */
    :where(.ma-split--mask .ma-split__part) {
      translate: 0 0;
      transition: translate var(--ma-r-dur, var(--ma-duration)) var(--ma-r-ease, var(--ma-ease)) calc(var(--ma-r-delay, 0ms) + var(--ma-r-i, 0) * var(--ma-r-stagger, var(--ma-stagger)));
    }
    :where([data-ma-js] .ma-split--mask:not(.ma-in) .ma-split__part) {
      translate: 0 110%;
    }
    :where([data-ma-js] .ma-split--top:not(.ma-in) .ma-split__part) {
      translate: 0 -110%;
    }
    :where(.ma-split--mask[data-leaving] .ma-split__part) {
      transition-duration: var(--ma-duration-fast);
      transition-delay: 0ms;
    }
    /* scrub: every word owns a slice of the block's own view timeline; nothing here runs where view() does not exist */
    @supports (animation-timeline: view()) {
      :where(.ma-split--scrub) {
        view-timeline-name: --ma-split;
      }
      :where(.ma-split--scrub .ma-split__part) {
        animation: ma-split-lit linear both;
      }
      /* separate rule on purpose — lightningcss would fold animation-timeline into the shorthand, which browsers reject */
      :where(.ma-split--scrub) :where(.ma-split__part) {
        animation-timeline: --ma-split;
        animation-range: cover calc(20% + var(--ma-split-i, 0) * var(--ma-split-k, 10%)) cover calc(28% + (var(--ma-split-i, 0) + 1) * var(--ma-split-k, 10%));
      }
    }
    @media (prefers-reduced-motion: reduce) {
      /* Reveal already drops its travel; the masked parts drop theirs and the scrub reads full. */
      :where([data-ma-js] .ma-split--mask:not(.ma-in) .ma-split__part) {
        translate: 0 0;
      }
      :where(.ma-split--mask .ma-split__part) {
        transition-duration: var(--ma-duration-fast);
        transition-delay: 0ms;
      }
      :where(.ma-split--scrub .ma-split__part) {
        animation: none;
      }
    }
    /* Print: Reveal shows the parts; the masked ones also come out from under their clip, and the scrub reads full. A
       masked line printed before it scrolled into view was blank. */
    @media print {
      :where(.ma-split--mask .ma-split__part) {
        translate: none;
        transition: none;
      }
      :where(.ma-split--scrub .ma-split__part) {
        animation: none;
      }
    }
  }

  @keyframes ma-split-lit {
    from {
      opacity: var(--ma-split-dim, 0.25);
    }
    to {
      opacity: 1;
    }
  }
</style>

```
