Skip to content

Components / Text

Split Text

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.

Live demo

Split on the server, revealed in view.

scrub: words light up as the block moves through the viewport, and dim again on the way back.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

Uses Reveal and the shared runtime (1.9 kB raw, once per site). With those included: 3.0 kB raw.

CSS 8.4 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser. Elsewhere: scrub uses animation-timeline: view() (Chrome 115, Safari 26); elsewhere every word is full.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import SplitText from '@moonarc/core/SplitText';
---
<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 />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/split-text.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy Reveal.astro and runtime.ts, and add the JS gate to your head.

SplitText.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>

Props

PropTypeDefaultDescription
textstringnoneThe text to split. Use \n for explicit lines with by="line".
asHTMLTag'span'Element to render.
by'word' | 'char' | 'line''word'Split unit.
stepnumbernoneStagger between parts in ms. Defaults: word 40, char 25, line 90.
maxTotalnumber600Cap on the total stagger in ms; the step shrinks to fit long text.
durationnumber500Per-part duration in ms.
from'bottom' | 'top' | 'none''bottom'Where parts travel from.
distancenumber12Travel distance in px.
blurnumbernoneBlur in px to sharpen from.
delaynumber0Delay before the first part, ms.
easingstringpreset curveAny CSS easing for each part. Unset, the preset's sampled spring.
oncebooleantruePlay once and stay.
thresholdnumber0.2Fraction visible to trigger.
rootMarginstring'0px 0px -10% 0px'IntersectionObserver rootMargin, passed to Reveal; the default waits until the text is 10% into the viewport. scrub ignores it.
maskbooleanfalseEach part is uncovered as it rises from behind a clip of its own line. from="top" drops it in instead.
scrubbooleanfalseScroll-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.
dimnumber0.25Resting opacity of an unread word in scrub mode.

Reduced motion

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

With ClientRouter

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

Why it is built this way

Replaces: SplitText (React Bits) · BlurText (React Bits) · TextReveal (Magic UI) · GSAP SplitText for entrances. See the migration table.

Cycles through a list of words in place.

newly · Chrome 85 · Firefox 128 · Safari 16.4always

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.

every browserscroll

Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.

Chrome 116 · not Firefox · Safari 18load

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown