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).

Loading

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.

prompt.md · Line Mask Reveal
### 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>