creative.
click to advance · loops automaticallyWhy SplitText
Plain CSS animations move the whole text block as one unit. SplitText wraps every character (or word, or line) in its own <span>, so you can stagger them, reverse them, or drive each one from scroll position independently.
Until GSAP 3.12, SplitText was a paid Club GSAP plugin. It's now free in the main gsap package.
Register the plugin
SplitText must be registered once before any usage. Do it at module scope in any client component.
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);In Next.js, keep all GSAP code inside "use client" components — the plugins access document, which doesn't exist during server rendering.
Split and animate
new SplitText(el, { type: "chars" }) wraps each character in a <span>. The instance gives you .chars, .words, and .lines arrays to target with GSAP.
"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
export function RevealHeading({ children }: { children: string }) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const split = new SplitText(el, { type: "chars" });
gsap.fromTo(
split.chars,
{ opacity: 0, y: 40, rotateX: -90, transformOrigin: "50% 0%" },
{
opacity: 1,
y: 0,
rotateX: 0,
stagger: 0.04,
duration: 0.55,
ease: "back.out(1.8)",
},
);
return () => split.revert();
}, [children]);
return (
<h1 ref={ref} style={{ perspective: "600px" }}>
{children}
</h1>
);
}perspective: "600px" on the parent is required for rotateX to look 3D rather than flat.
Wire it to scroll
Pair with ScrollTrigger so the reveal fires when the heading enters the viewport rather than on mount.
useEffect(() => {
const el = ref.current;
if (!el) return;
const split = new SplitText(el, { type: "chars" });
gsap.fromTo(
split.chars,
{ opacity: 0, y: 32 },
{
opacity: 1,
y: 0,
stagger: 0.035,
duration: 0.5,
ease: "power3.out",
scrollTrigger: {
trigger: el,
start: "top 85%",
once: true,
},
},
);
return () => split.revert();
}, []);once: true is important for headings — you don't want a title to disappear and reappear as the user scrolls back up.
Word and line splits
type: "chars" is the most granular. "words" groups by word, "lines" groups by the rendered line breaks. You can combine them:
const split = new SplitText(el, { type: "chars words lines" });
// split.chars → every character
// split.words → every word
// split.lines → every lineA common pattern: stagger words in, then stagger chars within each word for a layered feel.
gsap.timeline()
.from(split.lines, {
opacity: 0,
y: 20,
stagger: 0.1,
duration: 0.6,
ease: "power2.out",
})
.from(
split.chars,
{ opacity: 0, stagger: 0.008, duration: 0.4 },
"<0.1",
);Scramble text effect
GSAP's ScrambleTextPlugin pairs naturally with SplitText for the "hacker text" reveal — characters randomize before settling on the final value.
import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";
gsap.registerPlugin(ScrambleTextPlugin);
gsap.to(split.words, {
duration: 0.8,
scrambleText: {
text: "{original}",
chars: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
speed: 0.5,
},
stagger: 0.05,
ease: "none",
});Always revert on cleanup
SplitText injects spans directly into the DOM. If the component unmounts without reverting, orphaned spans can break hydration on the next render.
useEffect(() => {
const split = new SplitText(ref.current, { type: "chars" });
// ... animations ...
return () => {
split.revert(); // removes injected spans, restores original text
};
}, []);In React Strict Mode, effects run twice. split.revert() in the cleanup prevents double-split, which would produce nested <span><span>c</span></span> per character.
