GSAP-style SplitText headline that splits into chars, words, or measured lines and animates each piece in from a configurable offset, 3D tilt, opacity, and blur with real power/expo/back/elastic eases and start/center/end/edges/random staggers.
Preview
Motion that feels inevitable.
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add split-textWrites components/ui/split-text.tsx and components/styles/split-text.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { SplitText } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <SplitText />6 );7}Props
Accessibility
The full sentence is rendered once in a visually hidden span; the split pieces are aria-hidden so screen readers never hear letter-by-letter output. The heading element is configurable via `as`. Under prefers-reduced-motion the text renders in its settled state with no tween, and line splitting re-measures on resize via ResizeObserver.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/split-text.module.css";5 6export type SplitTextEase =7 "power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out";8 9export type SplitTextStaggerFrom = "start" | "center" | "end" | "edges" | "random";10 11export interface SplitTextProps {12 /** Text to split and animate */13 text?: string;14 /** Unit each animated piece is made of; "lines" measures the real line wraps */15 splitBy?: "chars" | "words" | "lines";16 /** GSAP-style ease name */17 ease?: SplitTextEase;18 /** Duration of each piece's tween in seconds */19 duration?: number;20 /** Delay between consecutive pieces in seconds */21 stagger?: number;22 /** Where the stagger wave starts from */23 staggerFrom?: SplitTextStaggerFrom;24 /** Delay before the first piece starts, in seconds */25 delay?: number;26 /** Starting vertical offset of each piece in pixels */27 fromY?: number;28 /** Starting 3D rotation around the X axis in degrees */29 fromRotateX?: number;30 /** Starting opacity (0–1) */31 fromOpacity?: number;32 /** Starting blur radius in pixels */33 fromBlur?: number;34 /** When the animation plays: on mount, when scrolled into view, or on every hover */35 trigger?: "mount" | "inView" | "hover";36 /** Element rendered as the root */37 as?: "h1" | "h2" | "h3" | "h4" | "p" | "div";38 /** Font size in pixels */39 fontSize?: number;40 /** Text color (defaults to the theme foreground) */41 color?: string;42 /** Optional className merged onto the root */43 className?: string;44 /** Optional inline styles merged onto the root */45 style?: React.CSSProperties;46}47 48const EASES: Record<SplitTextEase, (t: number) => number> = {49 "power2.out": (t) => 1 - Math.pow(1 - t, 3),50 "power3.out": (t) => 1 - Math.pow(1 - t, 4),51 "power4.out": (t) => 1 - Math.pow(1 - t, 5),52 "expo.out": (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),53 "back.out": (t) => {54 const c = 1.70158;55 const u = t - 1;56 return 1 + (c + 1) * u * u * u + c * u * u;57 },58 "elastic.out": (t) =>59 t <= 0 ? 0 : t >= 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1,60 "sine.inOut": (t) => -(Math.cos(Math.PI * t) - 1) / 2,61 "circ.out": (t) => Math.sqrt(1 - Math.pow(t - 1, 2)),62};63 64const rand = (n: number) => {65 const x = Math.sin(n * 9301 + 49297) * 233280;66 return x - Math.floor(x);67};68 69function staggerRanks(count: number, from: SplitTextStaggerFrom): number[] {70 if (count <= 0) return [];71 const mid = (count - 1) / 2;72 if (from === "random") {73 const ranks = new Array<number>(count).fill(0);74 Array.from({ length: count }, (_, i) => i)75 .sort((a, b) => rand(a + 1) - rand(b + 1))76 .forEach((idx, r) => {77 ranks[idx] = r;78 });79 return ranks;80 }81 const raw = Array.from({ length: count }, (_, i) => {82 if (from === "end") return count - 1 - i;83 if (from === "center") return Math.abs(i - mid);84 if (from === "edges") return mid - Math.abs(i - mid);85 return i;86 });87 const min = Math.min(...raw);88 return raw.map((v) => v - min);89}90 91const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;92 93const PIECE_ATTR = "data-mf-st-piece";94const WORD_ATTR = "data-mf-st-word";95 96export function SplitText({97 text = "Motion that feels inevitable.",98 splitBy = "chars",99 ease = "power4.out",100 duration = 0.9,101 stagger = 0.03,102 staggerFrom = "start",103 delay = 0,104 fromY = 64,105 fromRotateX = -70,106 fromOpacity = 0,107 fromBlur = 8,108 trigger = "mount",109 as = "h2",110 fontSize = 56,111 color,112 className = "",113 style,114}: SplitTextProps) {115 const rootRef = React.useRef<HTMLElement | null>(null);116 const groupsRef = React.useRef<number[]>([]);117 const words = React.useMemo(() => text.split(/\s+/).filter(Boolean), [text]);118 119 useIsoLayoutEffect(() => {120 const root = rootRef.current;121 if (!root) return;122 const pieces = Array.from(root.querySelectorAll<HTMLElement>(`[${PIECE_ATTR}]`));123 const wordEls = Array.from(root.querySelectorAll<HTMLElement>(`[${WORD_ATTR}]`));124 const easeFn = EASES[ease] ?? EASES["power4.out"];125 const dur = Math.max(0.01, duration);126 const opacity0 = Math.min(1, Math.max(0, fromOpacity));127 128 const measureGroups = () => {129 if (splitBy !== "lines") {130 groupsRef.current = pieces.map((_, i) => i);131 return;132 }133 // Real line wraps: group words by their untransformed offsetTop.134 const tops: number[] = [];135 groupsRef.current = wordEls.map((el) => {136 const top = el.offsetTop;137 let line = tops.findIndex((t) => Math.abs(t - top) < Math.max(2, el.offsetHeight * 0.4));138 if (line === -1) {139 tops.push(top);140 line = tops.length - 1;141 }142 return line;143 });144 };145 146 const apply = (el: HTMLElement, p: number, done: boolean) => {147 if (done) {148 el.style.transform = "";149 el.style.opacity = "";150 el.style.filter = "";151 return;152 }153 const q = Math.min(1, Math.max(0, p));154 const inv = 1 - p;155 el.style.transform = `perspective(600px) translate3d(0, ${(fromY * inv).toFixed(2)}px, 0) rotateX(${(156 fromRotateX * inv157 ).toFixed(2)}deg)`;158 el.style.opacity = String(opacity0 + (1 - opacity0) * q);159 const blur = fromBlur * (1 - q);160 el.style.filter = blur > 0.05 ? `blur(${blur.toFixed(2)}px)` : "";161 };162 163 let raf = 0;164 const play = () => {165 cancelAnimationFrame(raf);166 measureGroups();167 const groups = groupsRef.current;168 const groupCount = groups.length ? Math.max(...groups) + 1 : 0;169 const ranks = staggerRanks(groupCount, staggerFrom);170 const delays = pieces.map((_, i) => delay + (ranks[groups[i] ?? 0] ?? 0) * stagger);171 const finished = pieces.map(() => false);172 pieces.forEach((el) => apply(el, 0, false));173 const start = performance.now();174 const tick = (now: number) => {175 const t = (now - start) / 1000;176 let active = false;177 pieces.forEach((el, i) => {178 if (finished[i]) return;179 const local = (t - delays[i]) / dur;180 if (local >= 1) {181 finished[i] = true;182 apply(el, 1, true);183 } else {184 active = true;185 apply(el, easeFn(Math.max(0, local)), false);186 }187 });188 if (active) raf = requestAnimationFrame(tick);189 };190 raf = requestAnimationFrame(tick);191 };192 193 const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;194 if (reduced) {195 pieces.forEach((el) => apply(el, 1, true));196 return;197 }198 199 let ro: ResizeObserver | null = null;200 if (splitBy === "lines" && typeof ResizeObserver !== "undefined") {201 ro = new ResizeObserver(() => measureGroups());202 ro.observe(root);203 }204 205 let io: IntersectionObserver | null = null;206 const onEnter = () => play();207 208 if (trigger === "inView" && typeof IntersectionObserver !== "undefined") {209 pieces.forEach((el) => apply(el, 0, false));210 io = new IntersectionObserver(211 (entries) => {212 if (entries.some((e) => e.isIntersecting)) {213 io?.disconnect();214 play();215 }216 },217 { threshold: 0.2 },218 );219 io.observe(root);220 } else if (trigger === "hover") {221 measureGroups();222 root.addEventListener("pointerenter", onEnter);223 } else {224 play();225 }226 227 return () => {228 cancelAnimationFrame(raf);229 ro?.disconnect();230 io?.disconnect();231 root.removeEventListener("pointerenter", onEnter);232 pieces.forEach((el) => apply(el, 1, true));233 };234 }, [text, splitBy, ease, duration, stagger, staggerFrom, delay, fromY, fromRotateX, fromOpacity, fromBlur, trigger]);235 236 const Tag = as;237 const vars = {238 "--mf-st-size": `${fontSize}px`,239 ...(color ? { "--mf-st-color": color } : {}),240 ...style,241 } as React.CSSProperties;242 243 return (244 <Tag245 ref={(el: HTMLElement | null) => {246 rootRef.current = el;247 }}248 className={`${styles.root} ${className}`.trim()}249 style={vars}250 >251 <span className={styles.srOnly}>{text}</span>252 <span aria-hidden="true" className={styles.pieces}>253 {words.map((word, wi) => (254 <React.Fragment key={`${word}-${wi}`}>255 {splitBy === "chars" ? (256 <span className={styles.word} {...{ [WORD_ATTR]: "" }}>257 {Array.from(word).map((ch, ci) => (258 <span key={ci} className={styles.char} {...{ [PIECE_ATTR]: "" }}>259 {ch}260 </span>261 ))}262 </span>263 ) : (264 <span className={`${styles.word} ${styles.char}`} {...{ [WORD_ATTR]: "", [PIECE_ATTR]: "" }}>265 {word}266 </span>267 )}268 {wi < words.length - 1 ? " " : null}269 </React.Fragment>270 ))}271 </span>272 </Tag>273 );274}