SplitText
// preview
Text
Split by
charswords
Stagger from
startcenterendrandom
Stagger (ms)
Duration (ms)
Rise distance (px)
Tilt (deg)
// source
TSJS
TailwindCSS
/*!
* SplitText, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/split-text
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useMemo, useRef, type CSSProperties } from "react";
export interface SplitTextProps {
text: string;
/** Animate per character or per word. */
splitBy?: "chars" | "words";
/** Delay (ms) before the reveal starts once in view. */
delay?: number;
/** Gap (ms) between consecutive units. */
stagger?: number;
/** Where the stagger wave starts. */
staggerFrom?: "start" | "center" | "end" | "random";
/** Duration (ms) of each unit's reveal. */
duration?: number;
/** Initial vertical offset (px). */
yOffset?: number;
/** Initial tilt (deg), settles to 0 as the unit lands. */
tilt?: number;
/** WAAPI easing; the default overshoots slightly for a springy landing. */
easing?: string;
/** IntersectionObserver threshold that triggers the reveal. */
threshold?: number;
rootMargin?: string;
/** Play only the first time it enters the viewport. */
once?: boolean;
onComplete?: () => void;
className?: string;
style?: CSSProperties;
}
/** Grapheme-safe character split, keeps emoji and accents intact. */
function splitGraphemes(word: string): string[] {
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(word)].map(
(segment) => segment.segment,
);
}
return [...word];
}
/**
* Splits text into words or characters and reveals them in a staggered
* wave when scrolled into view. Powered by the Web Animations API: the
* server-rendered text stays intact for SEO and no-JS readers, and each
* unit animates only transform + opacity. Zero dependencies.
*/
export default function SplitText({
text,
splitBy = "chars",
delay = 0,
stagger = 34,
staggerFrom = "start",
duration = 700,
yOffset = 26,
tilt = 5,
easing = "cubic-bezier(0.22, 1.35, 0.36, 1)",
threshold = 0.15,
rootMargin = "0px",
once = true,
onComplete,
className = "",
style,
}: SplitTextProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const words = useMemo(() => text.split(/\s+/).filter(Boolean), [text]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const units = Array.from(container.querySelectorAll<HTMLElement>("[data-split-unit]"));
if (units.length === 0) return;
const count = units.length;
const order = units.map((_, i) => {
if (staggerFrom === "center") return Math.abs(i - (count - 1) / 2);
if (staggerFrom === "end") return count - 1 - i;
if (staggerFrom === "random") return Math.random() * count;
return i;
});
// Paused animations with backwards fill hide the units up front, so the
// SSR markup ships visible and only goes hidden once JS is ready.
const animations = units.map((unit, i) => {
const animation = unit.animate(
[
{
opacity: 0,
transform: `translate3d(0, ${yOffset}px, 0) rotate(${tilt}deg)`,
},
{ opacity: 1, transform: "translate3d(0, 0, 0) rotate(0deg)" },
],
{
duration,
delay: delay + order[i] * stagger,
easing,
fill: "both",
},
);
animation.pause();
return animation;
});
let played = false;
const play = () => {
for (const animation of animations) {
animation.currentTime = 0;
animation.play();
}
if (!played) {
played = true;
Promise.all(animations.map((animation) => animation.finished))
.then(() => onCompleteRef.current?.())
.catch(() => {});
}
};
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
play();
if (once) observer.disconnect();
}
},
{ threshold, rootMargin },
);
observer.observe(container);
return () => {
observer.disconnect();
for (const animation of animations) animation.cancel();
};
}, [
text,
splitBy,
delay,
stagger,
staggerFrom,
duration,
yOffset,
tilt,
easing,
threshold,
rootMargin,
once,
]);
return (
<span
ref={containerRef}
aria-label={text}
role="text"
className={`inline-block ${className}`}
style={style}
>
{words.map((word, wordIndex) => (
<React.Fragment key={`${word}-${wordIndex}`}>
{splitBy === "words" ? (
<span data-split-unit aria-hidden="true" className="inline-block will-change-transform">
{word}
</span>
) : (
// Chars are grouped per word in a nowrap box so lines still
// break between words, never through them.
<span aria-hidden="true" className="inline-block whitespace-nowrap">
{splitGraphemes(word).map((char, charIndex) => (
<span
key={charIndex}
data-split-unit
className="inline-block will-change-transform"
>
{char}
</span>
))}
</span>
)}
{wordIndex < words.length - 1 ? " " : null}
</React.Fragment>
))}
</span>
);
}
// install
Install the SplitText component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/SplitText-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.Need the license details? Read the component license.