Text Splitter
Split text into characters, words, or lines and animate them individually with stagger.
Text Splitter breaks a text element into individual <span> nodes — one per character, word, or line — so you can animate each unit independently with stagger.
Basic Usage
Add split to your animation config. The SDK wraps each unit in a <span>, then applies the animation to those spans instead of the parent element.
import { Motion } from "@motion.page/sdk";
Motion("headline", "h1", {
split: "chars",
from: { opacity: 0, y: 20 },
duration: 0.4,
stagger: 0.03,
ease: "power2.out",
}).onPageLoad(); Options
| Option | Type | Description |
|---|---|---|
split | string | Split type — see values below |
mask | boolean | Wrap each unit in an overflow: hidden container for clip-path-style reveals |
split Values
| Value | Description |
|---|---|
'chars' | Each character becomes a separate animatable span |
'words' | Each word becomes a separate animatable span |
'lines' | Each line (based on line wrapping) becomes a separate animatable span |
'chars,words' | Split into both chars and words — chars are nested inside word spans |
'words,lines' | Split into both words and lines — words are nested inside line spans |
'chars,words,lines' | All three levels — chars inside words inside lines |
For most text reveal animations, 'chars', 'words', or 'lines' is all you need.
Split Types
Characters
Split into individual characters. Use tight stagger values (0.02–0.05) so the effect feels snappy.
Motion("chars-in", "h1", {
split: "chars",
from: { opacity: 0, y: 20 },
duration: 0.4,
stagger: { each: 0.03, from: "start" },
ease: "power2.out",
}).onPageLoad(); Words
Split into words. Looser stagger (0.06–0.1) works well at this scale.
Motion("words-in", ".tagline", {
split: "words",
from: { opacity: 0, x: -15 },
duration: 0.5,
stagger: { each: 0.06, from: "start" },
ease: "power3.out",
}).onPageLoad(); Lines
Split into lines based on the element’s natural line wrapping. Use larger stagger values (0.1–0.2) since there are typically fewer units.
Motion("lines-in", "p", {
split: "lines",
from: { opacity: 0, y: 40 },
duration: 0.6,
stagger: { each: 0.15, from: "start" },
ease: "power2.out",
}).onPageLoad(); Mask Mode
Set mask: true to wrap each split unit in an overflow: hidden container. This lets you animate y: '110%' to slide text up from behind an invisible clip boundary — a cinematic newspaper-headline effect with no extra CSS required.
// Words slide up from behind a clip boundary
Motion("headline", "h1", {
split: "words",
mask: true,
from: { y: "110%" },
duration: 0.6,
stagger: { each: 0.06, from: "start" },
ease: "power3.out",
}).onPageLoad(); // Lines rise from below the clip
Motion("lines-mask", "h2", {
split: "lines",
mask: true,
from: { y: "110%" },
duration: 0.7,
stagger: 0.08,
ease: "power3.out",
}).onPageLoad(); Why 110% instead of 100%? A small extra offset (110%) ensures the starting position clears the clip boundary even with sub-pixel rounding. Use values between 100% and 120%.
The mask containers receive a [data-split-mask] attribute, so you can target them in CSS if needed.
Combining with Stagger
Split text is most powerful with Stagger. Stagger controls how each unit enters in sequence — adjust each, from, and ease to shape the cascade.
// Classic left-to-right char reveal
Motion("char-reveal", ".hero-title", {
split: "chars",
mask: true,
from: { y: "100%" },
duration: 0.5,
stagger: { each: 0.03, from: "start" },
ease: "expo.out",
}).onPageLoad();
// Center-outward word burst
Motion("word-burst", ".section-title", {
split: "words",
from: { opacity: 0, scale: 0.5 },
duration: 0.5,
stagger: { each: 0.1, from: "center" },
ease: "back.out",
}).onPageLoad();
// Random character order — different every play
Motion("char-random", ".scramble", {
split: "chars",
from: { opacity: 0, y: 20 },
duration: 0.4,
stagger: { each: 0.02, from: "random" },
ease: "power2.out",
}).onPageLoad(); Scroll-Triggered Text Reveal
Combine split text with scroll trigger to reveal text as it enters the viewport.
Motion("scroll-title", ".section-title", {
split: "words",
mask: true,
from: { y: "100%", opacity: 0 },
duration: 0.6,
stagger: { each: 0.05 },
ease: "power2.out",
}).onScroll({
each: true,
toggleActions: "play none none none",
start: "top 85%",
}); The each: true option on .onScroll() creates an independent scroll trigger per matched element — useful when the same selector targets multiple section titles.
CSS Targeting
The SDK adds data attributes to every split span. Use them to apply per-character styles in CSS.
Motion("styled-chars", "h1", {
split: "chars",
from: { opacity: 0, rotateY: 90 },
duration: 0.5,
stagger: 0.04,
}).onPageLoad(); /* Target individual split spans */
[data-split-char]:nth-child(3) { color: #6633EE; }
[data-split-word]:first-child { font-weight: bold; }
[data-split-line] { display: block; }
/* The root element is marked too */
[data-split] { /* styles applied to the container */ } | Attribute | Applied to |
|---|---|
data-split | Root element (the target you passed to Motion) |
data-split-char | Each character span |
data-split-word | Each word span |
data-split-line | Each line span |
Preserved Inline Elements
The splitter only touches bare text nodes. Existing inline elements like <span>, <em>, and <strong> survive splitting intact.
<!-- Input -->
<h1>Hello <span class="accent">World</span></h1>
<!-- After split: 'words' — the accent span is preserved -->
<h1 data-split>
<span data-split-word>Hello</span>
<span class="accent" data-split-word>World</span>
</h1> Cleanup and Revert
Killing the timeline reverts the DOM back to the original text content. No leftover span soup.
// Revert a specific element's split
Motion("headline").kill();
// Revert splits by target (without a named timeline)
Motion.reset(".hero-title"); Common Patterns
Typewriter Effect
Set duration: 0 for an instant snap per character, then use stagger to control the typing speed.
Motion("typewriter", ".terminal-text", {
split: "chars",
from: { opacity: 0 },
duration: 0,
stagger: { each: 0.07, from: "start" },
}).onPageLoad(); 3D Character Flip
Motion("char-flip", ".display-title", {
split: "chars",
from: { opacity: 0, rotateX: 90 },
duration: 0.5,
stagger: { each: 0.03, from: "start" },
ease: "power3.out",
}).onPageLoad(); Word Blur In
Motion("word-blur", ".subheading", {
split: "words",
from: { opacity: 0, filter: "blur(10px)" },
duration: 0.6,
stagger: { each: 0.08, from: "start" },
ease: "power2.out",
}).onPageLoad(); Lines + Mask on Scroll
Motion("lines-scroll", ".body-text", {
split: "lines",
mask: true,
from: { y: "110%" },
duration: 0.7,
stagger: { each: 0.12, from: "start" },
ease: "power3.out",
}).onScroll({
toggleActions: "play none none none",
start: "top 80%",
}); API Reference
interface AnimationConfig {
split?: 'chars' | 'words' | 'lines' | 'chars,words' | 'words,lines' | 'chars,words,lines';
mask?: boolean; // overflow:hidden clip wrapper per unit
}