Masked Text Reveal (GSAP SplitText)

Scroll-triggered text reveal where lines, words, or characters slide up from behind a hidden mask as the element enters the viewport. Includes a production-ready function with per-element split-type control and a simple basic version.

GSAPScrollTriggerSplitTextText RevealScroll

Setup — External Scripts

GSAP CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
ScrollTrigger CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
SplitText CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js"></script>

Code

index.html
html
<!-- Basic -->
<h1 data-split="heading">Your heading here</h1>

<!-- Advanced: control split type per element -->
<h1 data-split="heading" data-split-reveal="lines">Lines reveal</h1>
<h2 data-split="heading" data-split-reveal="words">Words reveal</h2>
<p  data-split="heading" data-split-reveal="chars">Characters reveal</p>
styles.css
css
/* Hide headings until JS runs to prevent FOUC */
[data-split="heading"] {
  visibility: hidden;
}
script.js
javascript
gsap.registerPlugin(SplitText, ScrollTrigger);

const splitConfig = {
  lines: { duration: 0.8, stagger: 0.08 },
  words: { duration: 0.6, stagger: 0.06 },
  chars: { duration: 0.4, stagger: 0.01 },
};

function initMaskTextScrollReveal() {
  document.querySelectorAll('[data-split="heading"]').forEach(heading => {
    gsap.set(heading, { autoAlpha: 1 });

    const type = heading.dataset.splitReveal || 'lines';
    const typesToSplit =
      type === 'lines' ? ['lines'] :
      type === 'words' ? ['lines', 'words'] :
      ['lines', 'words', 'chars'];

    SplitText.create(heading, {
      type: typesToSplit.join(', '),
      mask: 'lines',
      autoSplit: true,
      linesClass: 'line',
      wordsClass: 'word',
      charsClass: 'letter',
      onSplit(instance) {
        const targets = instance[type];
        const config  = splitConfig[type];

        return gsap.from(targets, {
          yPercent: 110,
          duration: config.duration,
          stagger: config.stagger,
          ease: 'expo.out',
          scrollTrigger: {
            trigger: heading,
            start: 'clamp(top 80%)',
            once: true,
          },
        });
      },
    });
  });
}

document.addEventListener("DOMContentLoaded", () => {
  document.fonts.ready.then(() => {
    initMaskTextScrollReveal();
  });
});

Notes

  • Only the minimum split depth needed is requested — `lines` only for line reveals, `lines + words` for word reveals — to keep the DOM lean.
  • `clamp(top 80%)` as the ScrollTrigger start value prevents the animation from firing mid-scroll if the element is already past the trigger point on page load.
  • `autoSplit: true` re-splits on resize and `onSplit` returning the tween lets GSAP clean up the old animation automatically before creating a new one.
  • SplitText v13+ automatically adds ARIA attributes to maintain screen-reader accessibility without any extra work.

Guide

Basic version

Just add `data-split="heading"` to any text element. Lines will slide up from a masked overflow on scroll. The `onSplit` callback returns the tween so GSAP can kill it before re-splitting on resize.

document.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll('[data-split="heading"]').forEach(heading => {
    SplitText.create(heading, {
      type: "lines",
      autoSplit: true,
      mask: "lines",
      onSplit(instance) {
        return gsap.from(instance.lines, {
          duration: 0.8,
          yPercent: 110,
          stagger: 0.1,
          ease: "expo.out",
          scrollTrigger: { trigger: heading, start: "top 80%", once: true }
        });
      }
    });
  });
});

Split type per element

Add `data-split-reveal` with a value of `lines`, `words`, or `chars` to control how each element is split. The `splitConfig` object at the top of the script defines the duration and stagger for each type.

Preventing FOUC

Hide elements with `visibility: hidden` in CSS and reveal them with `gsap.set(heading, { autoAlpha: 1 })` right before animating. Wrap initialisation in `document.fonts.ready` so splits happen after custom fonts are loaded.