Text FXAdvanced Text Animationsno dependencies

SplitText

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 props
0.9
0.03
64
-70
8
56

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add split-text

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

example.tsx
1import { SplitText } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <SplitText />6  );7}

Props

PropTypeDefault
text

Text to split and animate.

string'Motion that feels inevitable.'
splitBy

Unit each animated piece is made of; lines are measured from real wraps.

"chars" | "words" | "lines"'chars'
ease

GSAP-style ease name.

"power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out"'power4.out'
duration

Duration of each piece's tween in seconds.

number0.9
stagger

Delay between consecutive pieces in seconds.

number0.03
staggerFrom

Where the stagger wave starts from.

"start" | "center" | "end" | "edges" | "random"'start'
delay

Delay before the first piece starts, in seconds.

number0
fromY

Starting vertical offset of each piece in pixels.

number64
fromRotateX

Starting 3D rotation around the X axis in degrees.

number-70
fromOpacity

Starting opacity (0–1).

number0
fromBlur

Starting blur radius in pixels.

number8
trigger

When the animation plays.

"mount" | "inView" | "hover"'mount'
as

Element rendered as the root.

"h1" | "h2" | "h3" | "h4" | "p" | "div"'h2'
fontSize

Font size in pixels.

number56
color

Text color (defaults to the theme foreground).

stringundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element (CSS variables included).

React.CSSPropertiesundefined

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

components/ui/split-text.tsx
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}