L2

·

Text · scrub

Text that fills as you scroll

This is the only interaction in the lab whose job is to slow the reader down. Everything else here decorates; this one changes reading behaviour, which is why it belongs on exactly one paragraph per site — the one the whole page exists to deliver. Mechanically it is SplitText words plus a scrubbed stagger, with the pin in a separate ScrollTrigger from the word tween. That separation matters: autoSplit rebuilds the word tween whenever the font or the viewport changes, and if the pin were attached to that tween you would rebuild the pin too, which means a visible jump every time someone rotates their phone.

Open the live demo →

How to build

Panel + script

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

35 min. Tuning the scroll length takes longer than building it.

No-code?

Partly. The panel can pin and can scrub a SplitText stagger, so a basic version is no-code. What the panel cannot do is keep the pin and the split independent, so panel builds of this jump on resize.

01

Webflow structure

element · class · attribute

<b>Section</b> <i>statement_section</i> ← height 100svh, grid, place-items center <b>Paragraph</b> <i>statement_text</i> ← max-width ~24ch. THIS is what gets split. (inline <b>Span</b> for any phrase you want in the accent colour) <b>Section</b> <i>section_marker</i> <b>Paragraph</b> <i>marker_body</i> <b>Span</b> <i>marker_hl</i> ← the highlighted phrase … normal text … <b>Span</b> <i>marker_hl</i>

02

Styles to set in the Designer

statement_section | <code>height: 100svh</code>, <code>display: grid</code>, <code>place-items: center</code>. It gets pinned, so it must be exactly one screen tall or the pin has slack in it. statement_text | <code>max-width: 24ch</code> and <code>line-height: 1.22</code>. Wide measure ruins this — the eye needs to finish a line before the next one starts brightening. marker_hl | The whole trick, in CSS: <code>background-image: linear-gradient(green, green); background-repeat: no-repeat; background-size: calc(var(--mark,0) * 100%) 100%; box-decoration-break: clone;</code> marker_hl | <code>box-decoration-break: clone</code> is what makes a highlight that wraps across two lines paint on both. Without it the second line gets nothing.

03

Building it with the Interactions panel

no code, free plan
  1. Select statement_section → Interactions → trigger Scroll into view → enable Pin, distance ≈150% of the viewport.
  2. Select statement_text → add a Split Text (Words) animation → trigger While scrolling in view, scrubbed, over the same scroll range as the pin. From Opacity 14%, To Opacity 100%, staggered.
  3. For the marker variant: select each marker_hl span → While scrolling in view → animate a Background size property from 0% to 100%, Ease None.

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 ties the pin and the split to the same interaction lifecycle, so when the text re-splits on resize or a font swap, the pin rebuilds with it and the page visibly jumps. The script keeps the pin and the split as two independent triggers specifically to avoid that.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

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; document.fonts.ready.then(() => { const section = document.querySelector(".statement_section"); const text = document.querySelector(".statement_text"); // ── the pin lives on its own ──────────────────────────────────── // Keep it OUT of the split. autoSplit rebuilds the word tween on every // resize and font swap; rebuilding a pin at the same time makes the // page jump. Two triggers over the same range is the correct answer. ScrollTrigger.create({ trigger: section, start: "top top", end: "+=150%", pin: true, anticipatePin: 1, invalidateOnRefresh: true }); SplitText.create(text, { type: "words,lines", wordsClass: "word", autoSplit: true, onSplit(self) { // from(), not to(): words START dim and arrive at their real colour, // so nothing depends on JS to reach a readable state. return gsap.from(self.words, { opacity: 0.14, duration: 0.8, ease: "none", // scrubbed: any ease fights the scrollbar stagger: 0.45, // stagger : duration ratio sets the overlap scrollTrigger: { trigger: section, start: "top top", end: "+=150%", scrub: true, invalidateOnRefresh: true } }); } }); // ── the marker variant: one element, one custom property ────────── document.querySelectorAll(".marker_hl").forEach(mark => { gsap.to(mark, { "--mark": 1, color: "#0c0f0d", // text flips to dark as the ink arrives ease: "none", scrollTrigger: { trigger: mark, start: "top 78%", end: "top 55%", scrub: true } }); }); }); }); </script>

07

Gotchas that will cost you an hour

  1. The stagger-to-duration ratio is the whole feel. stagger 0.45 / duration 0.8 means each word is still arriving as the next starts — a wave. Equal values give you a hard one-at-a-time click that reads as a loading bar.
  2. ease: "none". On anything scrubbed. An eased scrub means the text accelerates independently of the user's hand and feels broken.
  3. Pin range of 150%, not 300%. The most common failure of this section is making it four screens long. If the visitor has to scroll past your statement for eight seconds, they scroll past it entirely.
  4. Split the paragraph, not a Rich Text wrapper. Target .statement_text directly. Splitting a rich text block flattens its structure and you lose your inline accent spans.
  5. box-decoration-break: clone for the marker. Without it a highlight that wraps to a second line paints only the first, which looks exactly like a broken build.
  6. Never do this to a paragraph nobody asked to read. Pinning is borrowing the user's scroll. One per site, on the sentence that matters.
08

Push it further

  • Split to chars instead of words and drop the stagger to 0.04 for the “laser wipe” version. Heavier on the DOM — check it on a mid-range Android before you ship it.
  • Swap the opacity fade for background-clip: text with a moving gradient: one element, one property, no DOM cost at all. That is the Osmo “gradient wave text”.
  • Fire a small ScrollTrigger callback on the last word to reveal the CTA underneath. The statement finishing is the moment they are most likely to click.

Webflow Motion Lab — GSAP 3.15, all plugins free.

19

/ 30