GSAP Text Animation: A Practical SplitText Guide (2026)
How to animate text with GSAP SplitText. Covers chars, words, and lines with scroll-triggered reveals, stagger, and mask effects. Copy-paste examples included.

Text animation is the thing that separates a good site from a great one. A headline that just sits there is wasted space. A headline that reveals itself as you read it, character by character, makes people stop scrolling.
The problem is most text animation tutorials either show you Framer Motion (not great for complex sequences) or raw CSS keyframes (painful to maintain). The right tool for this is GSAP SplitText. It gives you character, word, and line-level control. No splitting logic to write yourself. No wrapping divs by hand.
And as of GSAP 3.13, SplitText is completely free. No club membership required.
This guide covers the patterns I actually use in production. Working code included.
What SplitText Does
SplitText takes a text node and wraps each character, word, or line in a <div> or <span>. This gives GSAP something to animate independently.
Without SplitText, you can animate the whole element as one unit. With it, you can stagger each character, clip individual lines, or sequence words with precise timing.
One line of code:
const split = SplitText.create('.headline', { type: 'chars,words,lines' })
That gives you split.chars, split.words, and split.lines as arrays. Pass them to any GSAP method.
Setup
Install GSAP:
npm install gsap
Register SplitText in your JavaScript:
import { gsap } from 'gsap'
import { SplitText } from 'gsap/SplitText'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(SplitText, ScrollTrigger)
In React, register once at the module level and use the useGSAP hook:
import { gsap } from 'gsap'
import { SplitText } from 'gsap/SplitText'
import { useGSAP } from '@gsap/react'
import { useRef } from 'react'
if (typeof window !== 'undefined') {
gsap.registerPlugin(SplitText)
}
One important note: wait for fonts to load before splitting. If you split before the font renders, the character positions will be wrong.
useEffect(() => {
document.fonts.ready.then(() => setFontsLoaded(true))
}, [])
Pattern 1: Character Reveal on Scroll
The most common pattern. Each character fades and slides up as the element enters the viewport.
useGSAP(
() => {
if (!fontsLoaded) return
const split = SplitText.create(headlineRef.current, { type: 'chars' })
gsap.from(split.chars, {
opacity: 0,
y: 40,
duration: 0.6,
stagger: 0.02,
ease: 'expo.out',
scrollTrigger: {
trigger: headlineRef.current,
start: 'top 85%',
once: true,
},
})
return () => split.revert()
},
{ scope: containerRef, dependencies: [fontsLoaded] }
)
The stagger: 0.02 is the key. 20ms between each character. At 20 characters that's a 400ms cascade. Fast enough to feel snappy, slow enough to actually read.
For longer headlines, drop it to 0.01. For short ones (3-4 words), go up to 0.04.
You can see this pattern live in the Character Appear animation.
Pattern 2: Line Mask Reveal
This is the one you see on award-level sites. Each line of text slides up from behind a clipping container. The text appears to emerge from nothing.
The trick is splitting by lines and setting overflow: hidden on each wrapper.
useGSAP(
() => {
if (!fontsLoaded) return
const split = SplitText.create(textRef.current, {
type: 'lines',
linesClass: 'line-wrapper',
})
gsap.set('.line-wrapper', { overflow: 'hidden' })
gsap.from(split.lines, {
yPercent: 110,
duration: 1.0,
stagger: 0.1,
ease: 'expo.out',
scrollTrigger: {
trigger: textRef.current,
start: 'top 80%',
once: true,
},
})
return () => split.revert()
},
{ scope: containerRef, dependencies: [fontsLoaded] }
)
yPercent: 110 pushes the text just past the bottom of its container. When it animates to 0, it slides into view. Because the container clips it, there's no visible starting position.
This works best with large text (24px and above). For body copy, character or word reveals feel more natural.
The Text Reveal animation and Cinematic Text animation both use variations of this pattern.
Pattern 3: Word Stagger
Characters work for headlines. Lines work for big display text. Words work for body copy and subheadings where you want a more readable reveal.
useGSAP(
() => {
if (!fontsLoaded) return
const split = SplitText.create(paragraphRef.current, {
type: 'words',
})
gsap.from(split.words, {
opacity: 0,
y: 20,
filter: 'blur(4px)',
duration: 0.5,
stagger: 0.04,
ease: 'power3.out',
scrollTrigger: {
trigger: paragraphRef.current,
start: 'top 85%',
once: true,
},
})
return () => split.revert()
},
{ scope: containerRef, dependencies: [fontsLoaded] }
)
The filter: "blur(4px)" adds a subtle softness at the start. Animating filter has a small performance cost, so use it sparingly. On long paragraphs, skip it.
Pattern 4: Scramble Text
A different kind of effect: the text starts scrambled and resolves to the real content. Works well for technical or data-driven contexts.
GSAP has a plugin for this called ScrambleText, but you can get 80% of the effect with SplitText and a custom approach:
useGSAP(
() => {
if (!fontsLoaded) return
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const originalText = headlineRef.current.textContent
const split = SplitText.create(headlineRef.current, { type: 'chars' })
split.chars.forEach((char, i) => {
const original = char.textContent
let iterations = 0
const interval = setInterval(() => {
char.textContent = chars[Math.floor(Math.random() * chars.length)]
if (iterations >= i * 3) {
char.textContent = original
clearInterval(interval)
}
iterations++
}, 50)
})
return () => split.revert()
},
{ scope: containerRef, dependencies: [fontsLoaded] }
)
The delay is proportional to the character index. Earlier characters resolve first. This creates a left-to-right resolution that reads naturally.
The Text Scramble animation has a production-ready version of this with proper cleanup.
Pattern 5: Scroll-Scrubbed Text
Instead of triggering once on scroll, you tie the animation directly to scroll position. The text reveals as you scroll, pauses if you stop, reverses if you scroll back up.
useGSAP(
() => {
if (!fontsLoaded) return
const split = SplitText.create(headlineRef.current, { type: 'words' })
gsap.from(split.words, {
opacity: 0.1,
duration: 1,
stagger: 0.1,
ease: 'none',
scrollTrigger: {
trigger: headlineRef.current,
start: 'top 70%',
end: 'bottom 40%',
scrub: 1,
},
})
return () => split.revert()
},
{ scope: containerRef, dependencies: [fontsLoaded] }
)
scrub: 1 links the animation to scroll with a 1-second lag. The lag makes it feel physical rather than mechanical. ease: "none" on scrubbed animations is intentional. The scrollbar is the easing.
Performance Considerations
A few things that catch people out:
Avoid animating filter on mobile. Blur filters are GPU-accelerated on desktop but can cause frame drops on lower-end phones. Test on a mid-range Android before shipping.
Always call split.revert() on unmount. SplitText wraps your text in dozens of elements. If you don't revert, those stay in the DOM. In React, return the revert from your useGSAP callback.
Don't split too many elements at once. Splitting 10 paragraphs of body copy on page load is expensive. Use ScrollTrigger to split lazily: only split when the element is close to the viewport.
ScrollTrigger.create({
trigger: sectionRef.current,
start: 'top 120%',
onEnter: () => {
const split = SplitText.create(sectionRef.current.querySelectorAll('p'), {
type: 'lines',
})
// animate...
},
once: true,
})
Line-splitting is fragile on resize. When the window resizes, lines reflow and your split is wrong. Either re-split on resize (expensive) or use observeChanges: true in newer GSAP versions:
const split = SplitText.create(el, {
type: 'lines',
observeChanges: true,
})
This is available in GSAP 3.13+.
Accessibility
Splitting text breaks screen reader context. A headline that reads "Hello World" becomes 10 separate elements. Screen readers will announce each character individually.
Fix this with aria-label on the container and aria-hidden on the split elements:
const el = headlineRef.current
el.setAttribute('aria-label', el.textContent)
const split = SplitText.create(el, { type: 'chars' })
split.chars.forEach((char) => char.setAttribute('aria-hidden', 'true'))
Also respect prefers-reduced-motion. Users who've asked for reduced motion shouldn't get character animations regardless.
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!prefersReduced) {
const split = SplitText.create(el, { type: 'chars' })
gsap.from(split.chars, {
/* animation */
})
}
When to Use Which Type
| Split type | Best for | Avoid when |
|---|---|---|
chars | Headlines, short labels, hero text | Long paragraphs (too much DOM) |
words | Subheadings, medium-length copy | Very short text (awkward stagger) |
lines | Display text, large quotes | Body copy under 18px |
You can combine types. { type: "chars,lines" } gives you both arrays. Animate lines first, then chars inside each line for a compound effect.
Common Mistakes
Not reverting SplitText. The most common one. In React especially, not reverting on component unmount causes stale DOM nodes and broken re-renders.
Animating before fonts load. If your custom font loads after you split, all the line breaks are wrong. Always wait for document.fonts.ready.
Stagger too long. A 60-character headline with stagger: 0.05 takes 3 full seconds. That's too long. Keep the total cascade under 1 second for most headlines.
Forgetting force3D: true. Add it to all GSAP animations. Without it, transforms aren't hardware accelerated on every browser.
gsap.from(split.chars, {
y: 40,
opacity: 0,
force3D: true, // always
duration: 0.6,
stagger: 0.02,
ease: 'expo.out',
})
Skip the Setup
If you want text animations without wiring all of this up yourself, Annnimate has a full text animation collection. Each one ships with React and HTML code. Production-ready, scroll-triggered, and accessible out of the box.
- Character Appear — character-by-character reveal on scroll
- Text Reveal — line mask reveal for headings
- Cinematic Text — dramatic large-format text entrance
- Folding Text — perspective-based 3D line fold
- Text Scramble — randomized character resolve
- Popping Text — bouncy word-by-word pop in
Copy, paste, customize with data attributes. No GSAP setup required.
Summary
Text animation with GSAP SplitText comes down to three things: split the right unit (chars, words, or lines), animate with stagger, and always revert on cleanup.
The patterns above cover 90% of what you'll need in production. Start with the line mask reveal for headlines. Add character reveals for shorter labels. Use scroll-scrub for editorial content.
If you want to skip building from scratch, the Annnimate text animations are the production-ready versions of everything in this post.