MUST

·

Text · SplitText

Masked text reveal

This is the effect a client points at when they say “make it feel premium”. Text is split into lines, each line is wrapped in an overflow: hidden box, and the line slides up out of nowhere. Before 2025 you hand-wrapped every line in a div and it broke on every resize. SplitText now does the masking for you with mask: "lines", re-splits itself on font load and resize with autoSplit: true, and keeps the original text in the accessibility tree so screen readers still read a sentence, not 47 spans. Half the SplitText tutorials online are pre-rewrite and will teach you the hard way — ignore them.

Open the live demo →

How to build

Interactions panel

Free plan gets you part of the way

The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.

How the two routes work →

Plugins

SplitText · ScrollTrigger

Build time

15 min for the hero. Another 15 to apply it site-wide.

No-code?

Yes — SplitText is built into Webflow's GSAP Interactions panel, including masking, and it is licensed site-wide so you need no GSAP membership. Use the panel for the hero; use code when you want one rule to cover every heading on the site.

01

Webflow structure

element · class · attribute

<b>Section</b> <i>section_hero</i> <b>Div</b> <i>padding-global</i> <b>Div</b> <i>container-large</i> <b>Div</b> <i>hero_top</i> <b>Text</b> <i>hero_meta</i> <u>data-split="chars"</u> <b>Heading H1</b> <i>heading-style-h1</i> <u>data-split="lines"</u> <b>Div</b> <i>hero_foot</i> <b>Text</b> <i>hero_meta</i> <u>data-split="chars"</u> <b>Section</b> <i>section_manifesto</i> <b>Paragraph</b> <i>manifesto_p</i> <u>data-split="lines" data-split-scroll</u> <b>Heading H2</b> <i>heading-style-display</i> <u>data-split="words" data-split-scroll</u>

02

Styles to set in the Designer

heading-style-h1 | Set <code>line-height</code> to about 0.96–1.05 and give it a real <code>max-width</code> in ch. SplitText measures lines from the rendered box — if the width changes, the split changes. the split element | Leave <code>overflow</code> alone. <code>mask: "lines"</code> creates its own clipping wrappers; adding overflow hidden on the heading as well clips descenders (g, y, p) and looks like a bug. heading-style-display | If the type has a big negative letter-spacing, add ~0.08em <code>padding-bottom</code> so the mask does not shave the baseline.

03

Building it with the Interactions panel

no code, free plan
  1. Select the heading (heading-style-h1) → Interactions → add a Page load trigger, or Scroll into view for anything below the fold.
  2. In the animation step, set the target mode to Split Text → Lines (or Words / Characters) → tick Mask.
  3. Set From: Move Y 115%, To: Move Y 0%. Duration ~1.1s, Ease Expo Out, stagger ~0.08–0.11s between lines or words.
  4. For eyebrows and meta rows, split to Characters and use opacity-only From/To (0 → 100%) — no movement, since character movement on small text reads as broken kerning.
  5. Turn on conditional playback → No animation for reduced motion.

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

  • The panel's SplitText is wired per element, by hand. There's no single rule that reveals every heading on the site the way the attribute-driven script does — great for one hero, not for a site-wide standard.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

H1 / H2 | data-split | lines | Masked line reveal. The default for anything over ~32px. H2 display type | data-split | words | Words rise individually inside masked lines. More energy — use once per page. Eyebrows, meta rows | data-split | chars | Opacity only, no movement. Character movement on small text reads as broken kerning. Anything below the fold | data-split-scroll | (empty) | Waits for the element to enter instead of firing on load.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(SplitText, ScrollTrigger); if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; // Split before the webfont loads and every line breaks in the wrong place. document.fonts.ready.then(() => { document.querySelectorAll("[data-split]").forEach((el, i) => { const kind = el.dataset.split || "lines"; // lines | words | chars const onScrub = el.hasAttribute("data-split-scroll"); SplitText.create(el, { // masking only makes sense for things that move type: kind === "chars" ? "chars" : kind + ",lines", mask: kind === "chars" ? undefined : "lines", autoSplit: true, // re-split on resize / font swap, animation replays clean onSplit(self) { const targets = self[kind]; // self.lines | self.words | self.chars const vars = kind === "chars" ? { opacity: 0, duration: 0.4, ease: "none", stagger: 0.012 } : { yPercent: 115, duration: 1.1, ease: "expo.out", stagger: kind === "words" ? 0.05 : 0.11 }; if (onScrub) { vars.scrollTrigger = { trigger: self.elements[0], start: "top 85%", once: true }; } else { vars.delay = 0.1 + i * 0.05; // hero elements, sequenced on load } // Returning the tween lets SplitText kill and rebuild it on re-split. return gsap.from(targets, vars); } }); }); }); }); </script>

07

Gotchas that will cost you an hour

  1. Always wrap in document.fonts.ready. This is the number one SplitText bug. Split against the fallback font and the line breaks are computed for the wrong metrics — you get a heading that reveals in four lines then reflows to three.
  2. autoSplit: true is not optional on responsive sites. Rotate a phone without it and the masks stay at the old line positions, chopping the heading in half.
  3. Return the tween from onSplit. If you create the animation outside, every re-split leaves the old tween running on detached nodes — memory leak, and the text can freeze mid-reveal.
  4. Do not split a Rich Text block wholesale. Target the child elements (.w-richtext p, .w-richtext h2); splitting the wrapper flattens your paragraph structure.
  5. Descenders get clipped. If g/y/p look shaved, the fix is padding-bottom on the heading, not a bigger line-height — line-height changes where the lines break.
  6. Fires on load = fires above the fold only. Anything below the fold must use the scroll variant, or the user scrolls down to text that has already finished animating into place.
08

Push it further

  • Add a second target in the same onSplit — a rule under the heading that draws with scaleX at "-=0.6" — and you have a real hero lockup instead of one moving thing.
  • Hand the returned timeline to your preloader (the preloader lesson) so the hero starts the instant the loader clears rather than on a guessed delay.
  • For a scrub-linked version, drop once: true and add scrub: true — the words track the scrollbar. Use it on one section, never on the hero.

Webflow Motion Lab — GSAP 3.15, all plugins free.

5

/ 30