Line Mask Reveal
An editorial block — kicker, headline, three paragraphs and a byline — where every line rises out of an overflow mask as its block scrolls into view (SplitText lines, yPercent 100 → 0, 1.1s power4.out, 0.07s stagger).
- Text
- gsap
- scrolltrigger
- splittext
- beginner
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1233 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Line Mask Reveal
**What it does (visual):** A full-height intro ("Lines that surface", "Scroll ↓") leads into a narrow editorial article on the page background: a mono kicker, a large light serif headline, three paragraphs of body copy and a hairline-topped byline with a "Replay" button. Every text block is invisible until it scrolls into view. When a block's top crosses 85% of the viewport height, its lines rise one after another out of invisible horizontal slots: each line starts fully below its own baseline (clipped, so you see nothing), then travels up 100% of its height over 1.1s, decelerating hard at the end (`power4.out`), with the next line starting 0.07s later. A four-line paragraph is fully visible in ~1.3s; the headline and kicker reveal together as one block. Blocks reveal once and stay (reversible: false — when true, a block slides back into its masks as it scrolls out above the trigger line). The Replay button at the end fades in with the byline; clicking it replays every block that has already revealed.
**Tech stack:**
- GSAP 3.13+ with ScrollTrigger and SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`, `ScrollTrigger.min.js`, `SplitText.min.js`.
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(ScrollTrigger, SplitText);`
- Fonts: a light variable serif for the headline (demo: Fraunces 300), a sans for body copy, a mono for kicker/byline.
**HTML structure:**
```html
<section class="lmr-intro"><p class="lmr-intro__label">Text · Line mask reveal</p><h1 class="lmr-intro__title">Lines that surface</h1></section>
<article class="lmr">
<header class="lmr__block lmr__header">
<p class="lmr__kicker lmr__split">Field notes · No. 12 · Winter</p>
<h2 class="lmr__title lmr__split">The slow architecture of a tidal harbour</h2>
</header>
<div class="lmr__block"><p class="lmr__para lmr__split">Twice a day the harbour empties itself…</p></div>
<div class="lmr__block"><p class="lmr__para lmr__split">Nothing here was designed; it was corrected…</p></div>
<div class="lmr__block"><p class="lmr__para lmr__split">We came to photograph the light and stayed for the timetable…</p></div>
<footer class="lmr__block lmr__byline">
<p class="lmr__split">Words — Ines Aalto</p>
<p class="lmr__split">Photographs — Teodor Halvorsen</p>
<button class="lmr__replay" type="button">Replay</button>
</footer>
</article>
<section class="lmr-outro"><p>End of the article.</p></section>
```
`.lmr__block` = one ScrollTrigger; `.lmr__split` = the elements that get split (a block may contain several).
**CSS requirements:**
- `.lmr-intro`, `.lmr-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center;`
- `.lmr`: `max-width: 760px; margin: 0 auto; padding: 10vh clamp(1.25rem, 6vw, 4rem) 12vh; display: grid; gap: clamp(2.5rem, 6vh, 4.5rem);`
- `.lmr__split { visibility: hidden; }` — JS flips it to visible the moment the split exists (no FOUC, no layout shift).
- `.lmr__kicker`: mono 11px uppercase `letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem;`
- `.lmr__title`: display serif `font-weight: 300; font-size: clamp(2.5rem, 6.5vw, 6rem); letter-spacing: -0.04em; line-height: 1; text-wrap: balance;`
- `.lmr__para`: `font-size: clamp(1.1rem, 1.6vw, 1.45rem); line-height: 1.5; font-weight: 300; color: color-mix(in srgb, var(--text) 88%, var(--muted));`
- `.lmr__byline`: `display: grid; gap: .5rem; padding-top: 2rem; border-top: 1px solid var(--line);` mono 11px uppercase muted.
- `.lmr__replay`: outlined pill `border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; padding: .7em 1.4em; margin-top: 1.5rem; opacity: 0;` hover inverts; `:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }`
- Mask wrappers: `.lmr .lmr-line-mask { padding-bottom: .12em; margin-bottom: -.12em; }` (SplitText adds `overflow: clip` inline; the padding keeps descenders of the headline from being cut). `.lmr .lmr-line { will-change: transform; }`
- Reduced motion: `.lmr__split { visibility: visible; } .lmr__replay { display: none; }`
**Animation spec (exact values):**
Constants: `DURATION = 1.1`, `STAGGER = 0.07`, `EASE = 'power4.out'`, `TRAVEL = 100` (yPercent), `START = 'top 85%'`, `REVERSIBLE = false`.
1. `document.fonts.ready.then(() => { … })` — never split before fonts load.
2. For each `.lmr__block`:
```js
const targets = block.querySelectorAll('.lmr__split');
SplitText.create(targets, {
type: 'lines', mask: 'lines', linesClass: 'lmr-line', autoSplit: true, aria: 'auto',
onSplit(self) {
gsap.set(targets, { visibility: 'visible' });
const tl = gsap.timeline({ scrollTrigger: { trigger: block, start: 'top 85%', once: true, markers: false, onEnter: () => (entry.played = true) } });
// REVERSIBLE = false: when true, replace `once: true` with `toggleActions: 'play none none reverse'`
tl.from(self.lines, { yPercent: 100, duration: 1.1, ease: 'power4.out', stagger: 0.07 });
// byline block only:
tl.to('.lmr__replay', { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
return tl;
}
});
```
- Target: every `.lmr-line` of the block (all split elements, DOM order). Property `yPercent` from `100` → `0`. Duration 1.1s, ease `power4.out`, stagger 0.07s. `from()` renders immediately, so lines sit hidden inside their masks until the trigger fires.
- ScrollTrigger: trigger = the block, start `'top 85%'`, `once: true` (the trigger kills itself after playing) — or, when reversible (false) is true, `toggleActions: 'play none none reverse'` so the block hides again on the way back up; no scrub, no pin, markers false.
3. Replay: `entries.forEach(e => e.played && e.tl.restart())` — only blocks that have already revealed replay; blocks still below the fold keep waiting for their trigger.
4. Intro: `gsap.from('.lmr-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
**Interaction:** Scroll reveals blocks one at a time (no reverse — `once: true`). Hovering Replay inverts it over 0.25s; clicking restarts the revealed blocks' timelines. No pointer interaction on the text itself.
**Responsive behavior:** Sizes are fluid (`clamp()`), so under 768px paragraphs simply wrap into more lines — `autoSplit: true` re-splits on resize and, because `onSplit` returns the timeline, SplitText reverts and rebuilds it without duplicates. More lines × 0.07s makes a phone paragraph take ~0.3s longer, which reads fine; if you want identical total time use `gsap.matchMedia()` under 768px to set `STAGGER = 0.05`. The article padding collapses to `1.25rem` on phones.
**Accessibility:** With `prefers-reduced-motion: reduce`: skip splitting entirely, set `.lmr__split` to `visibility: visible` and hide the Replay button — the article is simply readable. `aria: 'auto'` makes SplitText put an `aria-label` with the full text on each split element and `aria-hidden` on the line pieces, so screen readers read the sentence, not fragments. Real heading hierarchy (`h1` intro, `h2` headline); the button has a visible focus ring.
**Performance rules:** Only `transform` (`yPercent`) and the button's `opacity` are animated. `will-change: transform` on the line elements only. Splitting by lines keeps tween counts tiny (one per line). Everything is created inside `gsap.context()`; `ctx.revert()` on unmount kills the ScrollTriggers and reverts all splits back to plain text nodes. Nothing runs per frame when no block is animating. Do not style `::first-letter` on split paragraphs — it is not applied during SplitText's measurement pass and causes a stray wrap.
**Customization knobs:**
- `DURATION` (1.1) / `STAGGER` (0.07) — the rhythm; 0.9 / 0.05 feels brisk, 1.4 / 0.1 feels ceremonial.
- `EASE` — `power4.out` (current); `power4.out` is the default, `expo.out` for a harder stop, `power2.out` for softer.
- `START` (`'top 85%'`) — use `'top 75%'` to reveal later in the viewport.
- `yPercent` (100) — 120 gives a touch more travel; keep ≥ 100 so lines are fully hidden at rest.
- `REVERSIBLE` (false) — remove `once: true` and add `toggleActions: 'play none none reverse'` for a reversible version.
**Acceptance criteria:**
- [ ] No text is visible before its block reaches 85% of the viewport; no layout shift when the split happens.
- [ ] Lines are clipped by their own mask (never visible below the baseline) and rise with a 0.07s cascade.
- [ ] Each block reveals once; scrolling back up does not hide it again (unless reversible = false is true).
- [ ] Replay restarts only the blocks that have revealed; resizing re-splits without duplicating lines.
- [ ] Reduced-motion users see the full article immediately with no Replay button.
- [ ] No console errors. Add this effect to my existing site/section: **Line Mask Reveal**. Keep my article markup, fonts and colours; add the class `lmr__block` to each unit that should reveal together (a heading group, each paragraph, the byline) and `lmr__split` to the text elements inside them (set `.lmr__split { visibility: hidden }`). Load GSAP 3.13+, ScrollTrigger and SplitText; after `document.fonts.ready`, for each block call `SplitText.create(block.querySelectorAll('.lmr__split'), { type: 'lines', mask: 'lines', autoSplit: true, aria: 'auto', onSplit(self) { … } })` and inside `onSplit` set the targets visible, build `gsap.timeline({ scrollTrigger: { trigger: block, start: 'top 85%', once: true } })` (reversible = false: if true use `toggleActions: 'play none none reverse'` instead of `once`) with `tl.from(self.lines, { yPercent: 100, duration: 1.1, ease: 'power4.out', stagger: 0.07 })`, and return the timeline. Give the mask wrappers `padding-bottom: .12em; margin-bottom: -.12em`. Optionally add a Replay button that restarts the timelines of blocks that have already played. Under `prefers-reduced-motion`, skip splitting and show the text. Wrap everything in `gsap.context()` and revert on unmount. ### EFFECT (React / Next.js): Line Mask Reveal
Build **Line Mask Reveal** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/LineMaskReveal.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);
export default function LineMaskReveal() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="lmr-intro">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`LineMaskReveal.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### EFFECT: Line Mask Reveal
**What it does (visual):** A full-height intro ("Lines that surface", "Scroll ↓") leads into a narrow editorial article on the page background: a mono kicker, a large light serif headline, three paragraphs of body copy and a hairline-topped byline with a "Replay" button. Every text block is invisible until it scrolls into view. When a block's top crosses 85% of the viewport height, its lines rise one after another out of invisible horizontal slots: each line starts fully below its own baseline (clipped, so you see nothing), then travels up 100% of its height over 1.1s, decelerating hard at the end (`power4.out`), with the next line starting 0.07s later. A four-line paragraph is fully visible in ~1.3s; the headline and kicker reveal together as one block. Blocks reveal once and stay (reversible: false — when true, a block slides back into its masks as it scrolls out above the trigger line). The Replay button at the end fades in with the byline; clicking it replays every block that has already revealed.
**Tech stack:**
- GSAP 3.13+ with ScrollTrigger and SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`, `ScrollTrigger.min.js`, `SplitText.min.js`.
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(ScrollTrigger, SplitText);`
- Fonts: a light variable serif for the headline (demo: Fraunces 300), a sans for body copy, a mono for kicker/byline.
**HTML structure:**
```html
<section class="lmr-intro"><p class="lmr-intro__label">Text · Line mask reveal</p><h1 class="lmr-intro__title">Lines that surface</h1></section>
<article class="lmr">
<header class="lmr__block lmr__header">
<p class="lmr__kicker lmr__split">Field notes · No. 12 · Winter</p>
<h2 class="lmr__title lmr__split">The slow architecture of a tidal harbour</h2>
</header>
<div class="lmr__block"><p class="lmr__para lmr__split">Twice a day the harbour empties itself…</p></div>
<div class="lmr__block"><p class="lmr__para lmr__split">Nothing here was designed; it was corrected…</p></div>
<div class="lmr__block"><p class="lmr__para lmr__split">We came to photograph the light and stayed for the timetable…</p></div>
<footer class="lmr__block lmr__byline">
<p class="lmr__split">Words — Ines Aalto</p>
<p class="lmr__split">Photographs — Teodor Halvorsen</p>
<button class="lmr__replay" type="button">Replay</button>
</footer>
</article>
<section class="lmr-outro"><p>End of the article.</p></section>
```
`.lmr__block` = one ScrollTrigger; `.lmr__split` = the elements that get split (a block may contain several).
**CSS requirements:**
- `.lmr-intro`, `.lmr-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center;`
- `.lmr`: `max-width: 760px; margin: 0 auto; padding: 10vh clamp(1.25rem, 6vw, 4rem) 12vh; display: grid; gap: clamp(2.5rem, 6vh, 4.5rem);`
- `.lmr__split { visibility: hidden; }` — JS flips it to visible the moment the split exists (no FOUC, no layout shift).
- `.lmr__kicker`: mono 11px uppercase `letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem;`
- `.lmr__title`: display serif `font-weight: 300; font-size: clamp(2.5rem, 6.5vw, 6rem); letter-spacing: -0.04em; line-height: 1; text-wrap: balance;`
- `.lmr__para`: `font-size: clamp(1.1rem, 1.6vw, 1.45rem); line-height: 1.5; font-weight: 300; color: color-mix(in srgb, var(--text) 88%, var(--muted));`
- `.lmr__byline`: `display: grid; gap: .5rem; padding-top: 2rem; border-top: 1px solid var(--line);` mono 11px uppercase muted.
- `.lmr__replay`: outlined pill `border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; padding: .7em 1.4em; margin-top: 1.5rem; opacity: 0;` hover inverts; `:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }`
- Mask wrappers: `.lmr .lmr-line-mask { padding-bottom: .12em; margin-bottom: -.12em; }` (SplitText adds `overflow: clip` inline; the padding keeps descenders of the headline from being cut). `.lmr .lmr-line { will-change: transform; }`
- Reduced motion: `.lmr__split { visibility: visible; } .lmr__replay { display: none; }`
**Animation spec (exact values):**
Constants: `DURATION = 1.1`, `STAGGER = 0.07`, `EASE = 'power4.out'`, `TRAVEL = 100` (yPercent), `START = 'top 85%'`, `REVERSIBLE = false`.
1. `document.fonts.ready.then(() => { … })` — never split before fonts load.
2. For each `.lmr__block`:
```js
const targets = block.querySelectorAll('.lmr__split');
SplitText.create(targets, {
type: 'lines', mask: 'lines', linesClass: 'lmr-line', autoSplit: true, aria: 'auto',
onSplit(self) {
gsap.set(targets, { visibility: 'visible' });
const tl = gsap.timeline({ scrollTrigger: { trigger: block, start: 'top 85%', once: true, markers: false, onEnter: () => (entry.played = true) } });
// REVERSIBLE = false: when true, replace `once: true` with `toggleActions: 'play none none reverse'`
tl.from(self.lines, { yPercent: 100, duration: 1.1, ease: 'power4.out', stagger: 0.07 });
// byline block only:
tl.to('.lmr__replay', { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
return tl;
}
});
```
- Target: every `.lmr-line` of the block (all split elements, DOM order). Property `yPercent` from `100` → `0`. Duration 1.1s, ease `power4.out`, stagger 0.07s. `from()` renders immediately, so lines sit hidden inside their masks until the trigger fires.
- ScrollTrigger: trigger = the block, start `'top 85%'`, `once: true` (the trigger kills itself after playing) — or, when reversible (false) is true, `toggleActions: 'play none none reverse'` so the block hides again on the way back up; no scrub, no pin, markers false.
3. Replay: `entries.forEach(e => e.played && e.tl.restart())` — only blocks that have already revealed replay; blocks still below the fold keep waiting for their trigger.
4. Intro: `gsap.from('.lmr-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
**Interaction:** Scroll reveals blocks one at a time (no reverse — `once: true`). Hovering Replay inverts it over 0.25s; clicking restarts the revealed blocks' timelines. No pointer interaction on the text itself.
**Responsive behavior:** Sizes are fluid (`clamp()`), so under 768px paragraphs simply wrap into more lines — `autoSplit: true` re-splits on resize and, because `onSplit` returns the timeline, SplitText reverts and rebuilds it without duplicates. More lines × 0.07s makes a phone paragraph take ~0.3s longer, which reads fine; if you want identical total time use `gsap.matchMedia()` under 768px to set `STAGGER = 0.05`. The article padding collapses to `1.25rem` on phones.
**Accessibility:** With `prefers-reduced-motion: reduce`: skip splitting entirely, set `.lmr__split` to `visibility: visible` and hide the Replay button — the article is simply readable. `aria: 'auto'` makes SplitText put an `aria-label` with the full text on each split element and `aria-hidden` on the line pieces, so screen readers read the sentence, not fragments. Real heading hierarchy (`h1` intro, `h2` headline); the button has a visible focus ring.
**Performance rules:** Only `transform` (`yPercent`) and the button's `opacity` are animated. `will-change: transform` on the line elements only. Splitting by lines keeps tween counts tiny (one per line). Everything is created inside `gsap.context()`; `ctx.revert()` on unmount kills the ScrollTriggers and reverts all splits back to plain text nodes. Nothing runs per frame when no block is animating. Do not style `::first-letter` on split paragraphs — it is not applied during SplitText's measurement pass and causes a stray wrap.
**Customization knobs:**
- `DURATION` (1.1) / `STAGGER` (0.07) — the rhythm; 0.9 / 0.05 feels brisk, 1.4 / 0.1 feels ceremonial.
- `EASE` — `power4.out` (current); `power4.out` is the default, `expo.out` for a harder stop, `power2.out` for softer.
- `START` (`'top 85%'`) — use `'top 75%'` to reveal later in the viewport.
- `yPercent` (100) — 120 gives a touch more travel; keep ≥ 100 so lines are fully hidden at rest.
- `REVERSIBLE` (false) — remove `once: true` and add `toggleActions: 'play none none reverse'` for a reversible version.
**Acceptance criteria:**
- [ ] No text is visible before its block reaches 85% of the viewport; no layout shift when the split happens.
- [ ] Lines are clipped by their own mask (never visible below the baseline) and rise with a 0.07s cascade.
- [ ] Each block reveals once; scrolling back up does not hide it again (unless reversible = false is true).
- [ ] Replay restarts only the blocks that have revealed; resizing re-splits without duplicating lines.
- [ ] Reduced-motion users see the full article immediately with no Replay button.
- [ ] No console errors.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="lmr-intro">
<p class="lmr-intro__label">Text · Line mask reveal</p>
<h1 class="lmr-intro__title">Lines that surface</h1>
<p class="lmr-intro__hint">Scroll ↓</p>
</section>
<article class="lmr">
<header class="lmr__block lmr__header">
<p class="lmr__kicker lmr__split">Field notes · No. 12 · Winter</p>
<h2 class="lmr__title lmr__split">The slow architecture of a tidal harbour</h2>
</header>
<div class="lmr__block">
<p class="lmr__para lmr__split">Twice a day the harbour empties itself. The boats settle onto the mud at an angle, the ladders grow a metre longer, and the town's whole geometry tilts to meet the water that isn't there.</p>
</div>
<div class="lmr__block">
<p class="lmr__para lmr__split">Nothing here was designed; it was corrected. Every wall is a reply to a storm, every step a compromise with a tide table printed before anyone alive was born. The result reads like a sentence that has been edited for three hundred years.</p>
</div>
<div class="lmr__block">
<p class="lmr__para lmr__split">We came to photograph the light and stayed for the timetable. At 04:10 the moorings creak awake. At 10:35 the quay is a floor again. By dusk the whole place is afloat, as if the morning had been a rumour.</p>
</div>
<footer class="lmr__block lmr__byline">
<p class="lmr__split">Words — Ines Aalto</p>
<p class="lmr__split">Photographs — Teodor Halvorsen</p>
<button class="lmr__replay" type="button">Replay</button>
</footer>
</article>
<section class="lmr-outro">
<p class="lmr-outro__text">End of the article — the page continues normally.</p>
</section>
```
```css
.lmr-intro, .lmr-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center; }
.lmr-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.lmr-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); letter-spacing: -0.04em; line-height: .9; font-variation-settings: 'opsz' 144, 'SOFT' 20; }
.lmr-intro__hint { margin-top: 3rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.lmr-outro__text { color: var(--muted); max-width: 40ch; }
.lmr { max-width: 760px; margin: 0 auto; padding: 10vh clamp(1.25rem, 6vw, 4rem) 12vh; display: grid; gap: clamp(2.5rem, 6vh, 4.5rem); }
.lmr__split { visibility: hidden; }
.lmr__kicker { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.lmr__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.5rem, 6.5vw, 6rem); letter-spacing: -0.04em; line-height: 1; text-wrap: balance; font-variation-settings: 'opsz' 144, 'SOFT' 30; }
.lmr__para { font-size: clamp(1.1rem, 1.6vw, 1.45rem); line-height: 1.5; color: color-mix(in srgb, var(--text) 88%, var(--muted)); font-weight: 300; }
.lmr__byline { display: grid; gap: .5rem; padding-top: 2rem; border-top: 1px solid var(--line); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.lmr__replay {
justify-self: start; margin-top: 1.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-family: var(--font-sans); font-size: .85rem; font-weight: 500; letter-spacing: 0; text-transform: none; color: var(--text); opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.lmr__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.lmr__replay:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* SplitText mask wrappers (linesClass + "-mask"): give descenders room */
.lmr .lmr-line-mask { padding-bottom: .12em; margin-bottom: -.12em; }
.lmr .lmr-line { will-change: transform; }
@media (prefers-reduced-motion: reduce) {
.lmr__split { visibility: visible; }
.lmr__replay { display: none; }
}
```
```js
// Line Mask Reveal — each line rises out of its own overflow mask, block by block on scroll.
gsap.registerPlugin(ScrollTrigger, SplitText);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { duration: 1.1, stagger: 0.07, ease: 'power4.out', travel: 100, start: 85, reversible: false };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const blocks = gsap.utils.toArray('.lmr__block');
const replay = document.querySelector('.lmr__replay');
let entries = []; // { tl, played } per block, for Replay — swapped on every build
// Registered once; it always reads the current build's entries.
replay.addEventListener('click', () => {
entries.forEach((e) => { if (e.played && e.tl) e.tl.restart(); });
});
let ctx;
function build() {
ctx?.revert(); // kills the ScrollTriggers and reverts every split back to plain text
entries = [];
const duration = K('duration'); // per-line travel time
const stagger = K('stagger'); // seconds between lines
const ease = K('ease');
const travel = K('travel'); // yPercent start offset
const start = `top ${K('start')}%`;
const reversible = !!K('reversible');
ctx = gsap.context(() => {
gsap.from('.lmr-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) gsap.set('.lmr__split', { visibility: 'visible' });
});
if (reduced) return;
// Splits happen after fonts load; ctx.add() records them in this build's context so a rebuild reverts them.
const mine = ctx;
document.fonts.ready.then(() => {
if (mine !== ctx) return; // a newer build replaced this one while fonts were loading
mine.add(() => {
blocks.forEach((block) => {
const targets = block.querySelectorAll('.lmr__split');
const entry = { tl: null, played: false };
entries.push(entry);
SplitText.create(targets, {
type: 'lines',
mask: 'lines',
linesClass: 'lmr-line',
autoSplit: true,
aria: 'auto',
onSplit(self) {
gsap.set(targets, { visibility: 'visible' });
const tl = gsap.timeline({
scrollTrigger: {
trigger: block, start, markers: false,
...(reversible ? { toggleActions: 'play none none reverse' } : { once: true }),
onEnter: () => { entry.played = true; },
},
});
tl.from(self.lines, { yPercent: travel, duration, ease, stagger });
if (block.contains(replay)) tl.to(replay, { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
entry.tl = tl;
return tl; // SplitText reverts + rebuilds this cleanly on resize
},
});
});
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): Line Mask Reveal
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Line Mask Reveal** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Line Mask Reveal</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Line Mask Reveal
**What it does (visual):** A full-height intro ("Lines that surface", "Scroll ↓") leads into a narrow editorial article on the page background: a mono kicker, a large light serif headline, three paragraphs of body copy and a hairline-topped byline with a "Replay" button. Every text block is invisible until it scrolls into view. When a block's top crosses 85% of the viewport height, its lines rise one after another out of invisible horizontal slots: each line starts fully below its own baseline (clipped, so you see nothing), then travels up 100% of its height over 1.1s, decelerating hard at the end (`power4.out`), with the next line starting 0.07s later. A four-line paragraph is fully visible in ~1.3s; the headline and kicker reveal together as one block. Blocks reveal once and stay (reversible: false — when true, a block slides back into its masks as it scrolls out above the trigger line). The Replay button at the end fades in with the byline; clicking it replays every block that has already revealed.
**Tech stack:**
- GSAP 3.13+ with ScrollTrigger and SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`, `ScrollTrigger.min.js`, `SplitText.min.js`.
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(ScrollTrigger, SplitText);`
- Fonts: a light variable serif for the headline (demo: Fraunces 300), a sans for body copy, a mono for kicker/byline.
**HTML structure:**
```html
<section class="lmr-intro"><p class="lmr-intro__label">Text · Line mask reveal</p><h1 class="lmr-intro__title">Lines that surface</h1></section>
<article class="lmr">
<header class="lmr__block lmr__header">
<p class="lmr__kicker lmr__split">Field notes · No. 12 · Winter</p>
<h2 class="lmr__title lmr__split">The slow architecture of a tidal harbour</h2>
</header>
<div class="lmr__block"><p class="lmr__para lmr__split">Twice a day the harbour empties itself…</p></div>
<div class="lmr__block"><p class="lmr__para lmr__split">Nothing here was designed; it was corrected…</p></div>
<div class="lmr__block"><p class="lmr__para lmr__split">We came to photograph the light and stayed for the timetable…</p></div>
<footer class="lmr__block lmr__byline">
<p class="lmr__split">Words — Ines Aalto</p>
<p class="lmr__split">Photographs — Teodor Halvorsen</p>
<button class="lmr__replay" type="button">Replay</button>
</footer>
</article>
<section class="lmr-outro"><p>End of the article.</p></section>
```
`.lmr__block` = one ScrollTrigger; `.lmr__split` = the elements that get split (a block may contain several).
**CSS requirements:**
- `.lmr-intro`, `.lmr-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center;`
- `.lmr`: `max-width: 760px; margin: 0 auto; padding: 10vh clamp(1.25rem, 6vw, 4rem) 12vh; display: grid; gap: clamp(2.5rem, 6vh, 4.5rem);`
- `.lmr__split { visibility: hidden; }` — JS flips it to visible the moment the split exists (no FOUC, no layout shift).
- `.lmr__kicker`: mono 11px uppercase `letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem;`
- `.lmr__title`: display serif `font-weight: 300; font-size: clamp(2.5rem, 6.5vw, 6rem); letter-spacing: -0.04em; line-height: 1; text-wrap: balance;`
- `.lmr__para`: `font-size: clamp(1.1rem, 1.6vw, 1.45rem); line-height: 1.5; font-weight: 300; color: color-mix(in srgb, var(--text) 88%, var(--muted));`
- `.lmr__byline`: `display: grid; gap: .5rem; padding-top: 2rem; border-top: 1px solid var(--line);` mono 11px uppercase muted.
- `.lmr__replay`: outlined pill `border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; padding: .7em 1.4em; margin-top: 1.5rem; opacity: 0;` hover inverts; `:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }`
- Mask wrappers: `.lmr .lmr-line-mask { padding-bottom: .12em; margin-bottom: -.12em; }` (SplitText adds `overflow: clip` inline; the padding keeps descenders of the headline from being cut). `.lmr .lmr-line { will-change: transform; }`
- Reduced motion: `.lmr__split { visibility: visible; } .lmr__replay { display: none; }`
**Animation spec (exact values):**
Constants: `DURATION = 1.1`, `STAGGER = 0.07`, `EASE = 'power4.out'`, `TRAVEL = 100` (yPercent), `START = 'top 85%'`, `REVERSIBLE = false`.
1. `document.fonts.ready.then(() => { … })` — never split before fonts load.
2. For each `.lmr__block`:
```js
const targets = block.querySelectorAll('.lmr__split');
SplitText.create(targets, {
type: 'lines', mask: 'lines', linesClass: 'lmr-line', autoSplit: true, aria: 'auto',
onSplit(self) {
gsap.set(targets, { visibility: 'visible' });
const tl = gsap.timeline({ scrollTrigger: { trigger: block, start: 'top 85%', once: true, markers: false, onEnter: () => (entry.played = true) } });
// REVERSIBLE = false: when true, replace `once: true` with `toggleActions: 'play none none reverse'`
tl.from(self.lines, { yPercent: 100, duration: 1.1, ease: 'power4.out', stagger: 0.07 });
// byline block only:
tl.to('.lmr__replay', { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
return tl;
}
});
```
- Target: every `.lmr-line` of the block (all split elements, DOM order). Property `yPercent` from `100` → `0`. Duration 1.1s, ease `power4.out`, stagger 0.07s. `from()` renders immediately, so lines sit hidden inside their masks until the trigger fires.
- ScrollTrigger: trigger = the block, start `'top 85%'`, `once: true` (the trigger kills itself after playing) — or, when reversible (false) is true, `toggleActions: 'play none none reverse'` so the block hides again on the way back up; no scrub, no pin, markers false.
3. Replay: `entries.forEach(e => e.played && e.tl.restart())` — only blocks that have already revealed replay; blocks still below the fold keep waiting for their trigger.
4. Intro: `gsap.from('.lmr-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
**Interaction:** Scroll reveals blocks one at a time (no reverse — `once: true`). Hovering Replay inverts it over 0.25s; clicking restarts the revealed blocks' timelines. No pointer interaction on the text itself.
**Responsive behavior:** Sizes are fluid (`clamp()`), so under 768px paragraphs simply wrap into more lines — `autoSplit: true` re-splits on resize and, because `onSplit` returns the timeline, SplitText reverts and rebuilds it without duplicates. More lines × 0.07s makes a phone paragraph take ~0.3s longer, which reads fine; if you want identical total time use `gsap.matchMedia()` under 768px to set `STAGGER = 0.05`. The article padding collapses to `1.25rem` on phones.
**Accessibility:** With `prefers-reduced-motion: reduce`: skip splitting entirely, set `.lmr__split` to `visibility: visible` and hide the Replay button — the article is simply readable. `aria: 'auto'` makes SplitText put an `aria-label` with the full text on each split element and `aria-hidden` on the line pieces, so screen readers read the sentence, not fragments. Real heading hierarchy (`h1` intro, `h2` headline); the button has a visible focus ring.
**Performance rules:** Only `transform` (`yPercent`) and the button's `opacity` are animated. `will-change: transform` on the line elements only. Splitting by lines keeps tween counts tiny (one per line). Everything is created inside `gsap.context()`; `ctx.revert()` on unmount kills the ScrollTriggers and reverts all splits back to plain text nodes. Nothing runs per frame when no block is animating. Do not style `::first-letter` on split paragraphs — it is not applied during SplitText's measurement pass and causes a stray wrap.
**Customization knobs:**
- `DURATION` (1.1) / `STAGGER` (0.07) — the rhythm; 0.9 / 0.05 feels brisk, 1.4 / 0.1 feels ceremonial.
- `EASE` — `power4.out` (current); `power4.out` is the default, `expo.out` for a harder stop, `power2.out` for softer.
- `START` (`'top 85%'`) — use `'top 75%'` to reveal later in the viewport.
- `yPercent` (100) — 120 gives a touch more travel; keep ≥ 100 so lines are fully hidden at rest.
- `REVERSIBLE` (false) — remove `once: true` and add `toggleActions: 'play none none reverse'` for a reversible version.
**Acceptance criteria:**
- [ ] No text is visible before its block reaches 85% of the viewport; no layout shift when the split happens.
- [ ] Lines are clipped by their own mask (never visible below the baseline) and rise with a 0.07s cascade.
- [ ] Each block reveals once; scrolling back up does not hide it again (unless reversible = false is true).
- [ ] Replay restarts only the blocks that have revealed; resizing re-splits without duplicating lines.
- [ ] Reduced-motion users see the full article immediately with no Replay button.
- [ ] No console errors.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="lmr-intro">
<p class="lmr-intro__label">Text · Line mask reveal</p>
<h1 class="lmr-intro__title">Lines that surface</h1>
<p class="lmr-intro__hint">Scroll ↓</p>
</section>
<article class="lmr">
<header class="lmr__block lmr__header">
<p class="lmr__kicker lmr__split">Field notes · No. 12 · Winter</p>
<h2 class="lmr__title lmr__split">The slow architecture of a tidal harbour</h2>
</header>
<div class="lmr__block">
<p class="lmr__para lmr__split">Twice a day the harbour empties itself. The boats settle onto the mud at an angle, the ladders grow a metre longer, and the town's whole geometry tilts to meet the water that isn't there.</p>
</div>
<div class="lmr__block">
<p class="lmr__para lmr__split">Nothing here was designed; it was corrected. Every wall is a reply to a storm, every step a compromise with a tide table printed before anyone alive was born. The result reads like a sentence that has been edited for three hundred years.</p>
</div>
<div class="lmr__block">
<p class="lmr__para lmr__split">We came to photograph the light and stayed for the timetable. At 04:10 the moorings creak awake. At 10:35 the quay is a floor again. By dusk the whole place is afloat, as if the morning had been a rumour.</p>
</div>
<footer class="lmr__block lmr__byline">
<p class="lmr__split">Words — Ines Aalto</p>
<p class="lmr__split">Photographs — Teodor Halvorsen</p>
<button class="lmr__replay" type="button">Replay</button>
</footer>
</article>
<section class="lmr-outro">
<p class="lmr-outro__text">End of the article — the page continues normally.</p>
</section>
```
```css
.lmr-intro, .lmr-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center; }
.lmr-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.lmr-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); letter-spacing: -0.04em; line-height: .9; font-variation-settings: 'opsz' 144, 'SOFT' 20; }
.lmr-intro__hint { margin-top: 3rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.lmr-outro__text { color: var(--muted); max-width: 40ch; }
.lmr { max-width: 760px; margin: 0 auto; padding: 10vh clamp(1.25rem, 6vw, 4rem) 12vh; display: grid; gap: clamp(2.5rem, 6vh, 4.5rem); }
.lmr__split { visibility: hidden; }
.lmr__kicker { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.lmr__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.5rem, 6.5vw, 6rem); letter-spacing: -0.04em; line-height: 1; text-wrap: balance; font-variation-settings: 'opsz' 144, 'SOFT' 30; }
.lmr__para { font-size: clamp(1.1rem, 1.6vw, 1.45rem); line-height: 1.5; color: color-mix(in srgb, var(--text) 88%, var(--muted)); font-weight: 300; }
.lmr__byline { display: grid; gap: .5rem; padding-top: 2rem; border-top: 1px solid var(--line); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.lmr__replay {
justify-self: start; margin-top: 1.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-family: var(--font-sans); font-size: .85rem; font-weight: 500; letter-spacing: 0; text-transform: none; color: var(--text); opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.lmr__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.lmr__replay:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* SplitText mask wrappers (linesClass + "-mask"): give descenders room */
.lmr .lmr-line-mask { padding-bottom: .12em; margin-bottom: -.12em; }
.lmr .lmr-line { will-change: transform; }
@media (prefers-reduced-motion: reduce) {
.lmr__split { visibility: visible; }
.lmr__replay { display: none; }
}
```
```js
// Line Mask Reveal — each line rises out of its own overflow mask, block by block on scroll.
gsap.registerPlugin(ScrollTrigger, SplitText);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { duration: 1.1, stagger: 0.07, ease: 'power4.out', travel: 100, start: 85, reversible: false };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const blocks = gsap.utils.toArray('.lmr__block');
const replay = document.querySelector('.lmr__replay');
let entries = []; // { tl, played } per block, for Replay — swapped on every build
// Registered once; it always reads the current build's entries.
replay.addEventListener('click', () => {
entries.forEach((e) => { if (e.played && e.tl) e.tl.restart(); });
});
let ctx;
function build() {
ctx?.revert(); // kills the ScrollTriggers and reverts every split back to plain text
entries = [];
const duration = K('duration'); // per-line travel time
const stagger = K('stagger'); // seconds between lines
const ease = K('ease');
const travel = K('travel'); // yPercent start offset
const start = `top ${K('start')}%`;
const reversible = !!K('reversible');
ctx = gsap.context(() => {
gsap.from('.lmr-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) gsap.set('.lmr__split', { visibility: 'visible' });
});
if (reduced) return;
// Splits happen after fonts load; ctx.add() records them in this build's context so a rebuild reverts them.
const mine = ctx;
document.fonts.ready.then(() => {
if (mine !== ctx) return; // a newer build replaced this one while fonts were loading
mine.add(() => {
blocks.forEach((block) => {
const targets = block.querySelectorAll('.lmr__split');
const entry = { tl: null, played: false };
entries.push(entry);
SplitText.create(targets, {
type: 'lines',
mask: 'lines',
linesClass: 'lmr-line',
autoSplit: true,
aria: 'auto',
onSplit(self) {
gsap.set(targets, { visibility: 'visible' });
const tl = gsap.timeline({
scrollTrigger: {
trigger: block, start, markers: false,
...(reversible ? { toggleActions: 'play none none reverse' } : { once: true }),
onEnter: () => { entry.played = true; },
},
});
tl.from(self.lines, { yPercent: travel, duration, ease, stagger });
if (block.contains(replay)) tl.to(replay, { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
entry.tl = tl;
return tl; // SplitText reverts + rebuilds this cleanly on resize
},
});
});
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): Line Mask Reveal
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withLineMaskReveal(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis. ScrollTrigger works on the page scroller — call `ScrollTrigger.refresh()` after images load.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `lmr-intro`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the pinning/scrubbing in code — IX2 cannot do pin spacing or `containerAnimation`.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Line Mask Reveal
**What it does (visual):** A full-height intro ("Lines that surface", "Scroll ↓") leads into a narrow editorial article on the page background: a mono kicker, a large light serif headline, three paragraphs of body copy and a hairline-topped byline with a "Replay" button. Every text block is invisible until it scrolls into view. When a block's top crosses 85% of the viewport height, its lines rise one after another out of invisible horizontal slots: each line starts fully below its own baseline (clipped, so you see nothing), then travels up 100% of its height over 1.1s, decelerating hard at the end (`power4.out`), with the next line starting 0.07s later. A four-line paragraph is fully visible in ~1.3s; the headline and kicker reveal together as one block. Blocks reveal once and stay (reversible: false — when true, a block slides back into its masks as it scrolls out above the trigger line). The Replay button at the end fades in with the byline; clicking it replays every block that has already revealed.
**Tech stack:**
- GSAP 3.13+ with ScrollTrigger and SplitText (free since 3.13).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`, `ScrollTrigger.min.js`, `SplitText.min.js`.
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(ScrollTrigger, SplitText);`
- Fonts: a light variable serif for the headline (demo: Fraunces 300), a sans for body copy, a mono for kicker/byline.
**HTML structure:**
```html
<section class="lmr-intro"><p class="lmr-intro__label">Text · Line mask reveal</p><h1 class="lmr-intro__title">Lines that surface</h1></section>
<article class="lmr">
<header class="lmr__block lmr__header">
<p class="lmr__kicker lmr__split">Field notes · No. 12 · Winter</p>
<h2 class="lmr__title lmr__split">The slow architecture of a tidal harbour</h2>
</header>
<div class="lmr__block"><p class="lmr__para lmr__split">Twice a day the harbour empties itself…</p></div>
<div class="lmr__block"><p class="lmr__para lmr__split">Nothing here was designed; it was corrected…</p></div>
<div class="lmr__block"><p class="lmr__para lmr__split">We came to photograph the light and stayed for the timetable…</p></div>
<footer class="lmr__block lmr__byline">
<p class="lmr__split">Words — Ines Aalto</p>
<p class="lmr__split">Photographs — Teodor Halvorsen</p>
<button class="lmr__replay" type="button">Replay</button>
</footer>
</article>
<section class="lmr-outro"><p>End of the article.</p></section>
```
`.lmr__block` = one ScrollTrigger; `.lmr__split` = the elements that get split (a block may contain several).
**CSS requirements:**
- `.lmr-intro`, `.lmr-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center;`
- `.lmr`: `max-width: 760px; margin: 0 auto; padding: 10vh clamp(1.25rem, 6vw, 4rem) 12vh; display: grid; gap: clamp(2.5rem, 6vh, 4.5rem);`
- `.lmr__split { visibility: hidden; }` — JS flips it to visible the moment the split exists (no FOUC, no layout shift).
- `.lmr__kicker`: mono 11px uppercase `letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem;`
- `.lmr__title`: display serif `font-weight: 300; font-size: clamp(2.5rem, 6.5vw, 6rem); letter-spacing: -0.04em; line-height: 1; text-wrap: balance;`
- `.lmr__para`: `font-size: clamp(1.1rem, 1.6vw, 1.45rem); line-height: 1.5; font-weight: 300; color: color-mix(in srgb, var(--text) 88%, var(--muted));`
- `.lmr__byline`: `display: grid; gap: .5rem; padding-top: 2rem; border-top: 1px solid var(--line);` mono 11px uppercase muted.
- `.lmr__replay`: outlined pill `border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; padding: .7em 1.4em; margin-top: 1.5rem; opacity: 0;` hover inverts; `:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }`
- Mask wrappers: `.lmr .lmr-line-mask { padding-bottom: .12em; margin-bottom: -.12em; }` (SplitText adds `overflow: clip` inline; the padding keeps descenders of the headline from being cut). `.lmr .lmr-line { will-change: transform; }`
- Reduced motion: `.lmr__split { visibility: visible; } .lmr__replay { display: none; }`
**Animation spec (exact values):**
Constants: `DURATION = 1.1`, `STAGGER = 0.07`, `EASE = 'power4.out'`, `TRAVEL = 100` (yPercent), `START = 'top 85%'`, `REVERSIBLE = false`.
1. `document.fonts.ready.then(() => { … })` — never split before fonts load.
2. For each `.lmr__block`:
```js
const targets = block.querySelectorAll('.lmr__split');
SplitText.create(targets, {
type: 'lines', mask: 'lines', linesClass: 'lmr-line', autoSplit: true, aria: 'auto',
onSplit(self) {
gsap.set(targets, { visibility: 'visible' });
const tl = gsap.timeline({ scrollTrigger: { trigger: block, start: 'top 85%', once: true, markers: false, onEnter: () => (entry.played = true) } });
// REVERSIBLE = false: when true, replace `once: true` with `toggleActions: 'play none none reverse'`
tl.from(self.lines, { yPercent: 100, duration: 1.1, ease: 'power4.out', stagger: 0.07 });
// byline block only:
tl.to('.lmr__replay', { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
return tl;
}
});
```
- Target: every `.lmr-line` of the block (all split elements, DOM order). Property `yPercent` from `100` → `0`. Duration 1.1s, ease `power4.out`, stagger 0.07s. `from()` renders immediately, so lines sit hidden inside their masks until the trigger fires.
- ScrollTrigger: trigger = the block, start `'top 85%'`, `once: true` (the trigger kills itself after playing) — or, when reversible (false) is true, `toggleActions: 'play none none reverse'` so the block hides again on the way back up; no scrub, no pin, markers false.
3. Replay: `entries.forEach(e => e.played && e.tl.restart())` — only blocks that have already revealed replay; blocks still below the fold keep waiting for their trigger.
4. Intro: `gsap.from('.lmr-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
**Interaction:** Scroll reveals blocks one at a time (no reverse — `once: true`). Hovering Replay inverts it over 0.25s; clicking restarts the revealed blocks' timelines. No pointer interaction on the text itself.
**Responsive behavior:** Sizes are fluid (`clamp()`), so under 768px paragraphs simply wrap into more lines — `autoSplit: true` re-splits on resize and, because `onSplit` returns the timeline, SplitText reverts and rebuilds it without duplicates. More lines × 0.07s makes a phone paragraph take ~0.3s longer, which reads fine; if you want identical total time use `gsap.matchMedia()` under 768px to set `STAGGER = 0.05`. The article padding collapses to `1.25rem` on phones.
**Accessibility:** With `prefers-reduced-motion: reduce`: skip splitting entirely, set `.lmr__split` to `visibility: visible` and hide the Replay button — the article is simply readable. `aria: 'auto'` makes SplitText put an `aria-label` with the full text on each split element and `aria-hidden` on the line pieces, so screen readers read the sentence, not fragments. Real heading hierarchy (`h1` intro, `h2` headline); the button has a visible focus ring.
**Performance rules:** Only `transform` (`yPercent`) and the button's `opacity` are animated. `will-change: transform` on the line elements only. Splitting by lines keeps tween counts tiny (one per line). Everything is created inside `gsap.context()`; `ctx.revert()` on unmount kills the ScrollTriggers and reverts all splits back to plain text nodes. Nothing runs per frame when no block is animating. Do not style `::first-letter` on split paragraphs — it is not applied during SplitText's measurement pass and causes a stray wrap.
**Customization knobs:**
- `DURATION` (1.1) / `STAGGER` (0.07) — the rhythm; 0.9 / 0.05 feels brisk, 1.4 / 0.1 feels ceremonial.
- `EASE` — `power4.out` (current); `power4.out` is the default, `expo.out` for a harder stop, `power2.out` for softer.
- `START` (`'top 85%'`) — use `'top 75%'` to reveal later in the viewport.
- `yPercent` (100) — 120 gives a touch more travel; keep ≥ 100 so lines are fully hidden at rest.
- `REVERSIBLE` (false) — remove `once: true` and add `toggleActions: 'play none none reverse'` for a reversible version.
**Acceptance criteria:**
- [ ] No text is visible before its block reaches 85% of the viewport; no layout shift when the split happens.
- [ ] Lines are clipped by their own mask (never visible below the baseline) and rise with a 0.07s cascade.
- [ ] Each block reveals once; scrolling back up does not hide it again (unless reversible = false is true).
- [ ] Replay restarts only the blocks that have revealed; resizing re-splits without duplicating lines.
- [ ] Reduced-motion users see the full article immediately with no Replay button.
- [ ] No console errors.
Source
Full source code
<section class="lmr-intro">
<p class="lmr-intro__label">Text · Line mask reveal</p>
<h1 class="lmr-intro__title">Lines that surface</h1>
<p class="lmr-intro__hint">Scroll ↓</p>
</section>
<article class="lmr">
<header class="lmr__block lmr__header">
<p class="lmr__kicker lmr__split">Field notes · No. 12 · Winter</p>
<h2 class="lmr__title lmr__split">The slow architecture of a tidal harbour</h2>
</header>
<div class="lmr__block">
<p class="lmr__para lmr__split">Twice a day the harbour empties itself. The boats settle onto the mud at an angle, the ladders grow a metre longer, and the town's whole geometry tilts to meet the water that isn't there.</p>
</div>
<div class="lmr__block">
<p class="lmr__para lmr__split">Nothing here was designed; it was corrected. Every wall is a reply to a storm, every step a compromise with a tide table printed before anyone alive was born. The result reads like a sentence that has been edited for three hundred years.</p>
</div>
<div class="lmr__block">
<p class="lmr__para lmr__split">We came to photograph the light and stayed for the timetable. At 04:10 the moorings creak awake. At 10:35 the quay is a floor again. By dusk the whole place is afloat, as if the morning had been a rumour.</p>
</div>
<footer class="lmr__block lmr__byline">
<p class="lmr__split">Words — Ines Aalto</p>
<p class="lmr__split">Photographs — Teodor Halvorsen</p>
<button class="lmr__replay" type="button">Replay</button>
</footer>
</article>
<section class="lmr-outro">
<p class="lmr-outro__text">End of the article — the page continues normally.</p>
</section>
.lmr-intro, .lmr-outro { min-height: 100vh; display: grid; place-content: center; padding: 6vw; text-align: center; }
.lmr-intro__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.lmr-intro__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); letter-spacing: -0.04em; line-height: .9; font-variation-settings: 'opsz' 144, 'SOFT' 20; }
.lmr-intro__hint { margin-top: 3rem; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.lmr-outro__text { color: var(--muted); max-width: 40ch; }
.lmr { max-width: 760px; margin: 0 auto; padding: 10vh clamp(1.25rem, 6vw, 4rem) 12vh; display: grid; gap: clamp(2.5rem, 6vh, 4.5rem); }
.lmr__split { visibility: hidden; }
.lmr__kicker { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.lmr__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.5rem, 6.5vw, 6rem); letter-spacing: -0.04em; line-height: 1; text-wrap: balance; font-variation-settings: 'opsz' 144, 'SOFT' 30; }
.lmr__para { font-size: clamp(1.1rem, 1.6vw, 1.45rem); line-height: 1.5; color: color-mix(in srgb, var(--text) 88%, var(--muted)); font-weight: 300; }
.lmr__byline { display: grid; gap: .5rem; padding-top: 2rem; border-top: 1px solid var(--line); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); }
.lmr__replay {
justify-self: start; margin-top: 1.5rem; padding: .7em 1.4em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-family: var(--font-sans); font-size: .85rem; font-weight: 500; letter-spacing: 0; text-transform: none; color: var(--text); opacity: 0; transition: background .25s, color .25s, border-color .25s;
}
.lmr__replay:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.lmr__replay:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* SplitText mask wrappers (linesClass + "-mask"): give descenders room */
.lmr .lmr-line-mask { padding-bottom: .12em; margin-bottom: -.12em; }
.lmr .lmr-line { will-change: transform; }
@media (prefers-reduced-motion: reduce) {
.lmr__split { visibility: visible; }
.lmr__replay { display: none; }
}
// Line Mask Reveal — each line rises out of its own overflow mask, block by block on scroll.
gsap.registerPlugin(ScrollTrigger, SplitText);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { duration: 1.1, stagger: 0.07, ease: 'power4.out', travel: 100, start: 85, reversible: false };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const blocks = gsap.utils.toArray('.lmr__block');
const replay = document.querySelector('.lmr__replay');
let entries = []; // { tl, played } per block, for Replay — swapped on every build
// Registered once; it always reads the current build's entries.
replay.addEventListener('click', () => {
entries.forEach((e) => { if (e.played && e.tl) e.tl.restart(); });
});
let ctx;
function build() {
ctx?.revert(); // kills the ScrollTriggers and reverts every split back to plain text
entries = [];
const duration = K('duration'); // per-line travel time
const stagger = K('stagger'); // seconds between lines
const ease = K('ease');
const travel = K('travel'); // yPercent start offset
const start = `top ${K('start')}%`;
const reversible = !!K('reversible');
ctx = gsap.context(() => {
gsap.from('.lmr-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 });
if (reduced) gsap.set('.lmr__split', { visibility: 'visible' });
});
if (reduced) return;
// Splits happen after fonts load; ctx.add() records them in this build's context so a rebuild reverts them.
const mine = ctx;
document.fonts.ready.then(() => {
if (mine !== ctx) return; // a newer build replaced this one while fonts were loading
mine.add(() => {
blocks.forEach((block) => {
const targets = block.querySelectorAll('.lmr__split');
const entry = { tl: null, played: false };
entries.push(entry);
SplitText.create(targets, {
type: 'lines',
mask: 'lines',
linesClass: 'lmr-line',
autoSplit: true,
aria: 'auto',
onSplit(self) {
gsap.set(targets, { visibility: 'visible' });
const tl = gsap.timeline({
scrollTrigger: {
trigger: block, start, markers: false,
...(reversible ? { toggleActions: 'play none none reverse' } : { once: true }),
onEnter: () => { entry.played = true; },
},
});
tl.from(self.lines, { yPercent: travel, duration, ease, stagger });
if (block.contains(replay)) tl.to(replay, { opacity: 1, duration: 0.6, ease: 'power2.out' }, '-=0.6');
entry.tl = tl;
return tl; // SplitText reverts + rebuilds this cleanly on resize
},
});
});
});
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());