Rotating Text
TextCycles through a list of words in place.
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.
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.
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
every browser. Elsewhere: scrub uses animation-timeline: view() (Chrome 115, Safari 26); elsewhere every word is full.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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 />Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/split-text.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
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 — 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>| 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. |
Inherits Reveal: fast opacity fade, no travel, no stagger.
ClientRouterInherits Reveal: rebinds through the shared runtime after every navigation.
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.
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.
Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown