← Blog

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.

GSAP SplitText tutorial showing character and line text animations with scroll triggers

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:

script.js
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:

terminal
npm install gsap

Register SplitText in your JavaScript:

script.js
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:

script.js
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.

script.js
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.

script.js
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.

script.js
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.

script.js
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:

script.js
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.

script.js
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.

script.js
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:

script.js
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:

script.js
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.

script.js
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 typeBest forAvoid when
charsHeadlines, short labels, hero textLong paragraphs (too much DOM)
wordsSubheadings, medium-length copyVery short text (awkward stagger)
linesDisplay text, large quotesBody 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.

script.js
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.

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.