Text Highlight Fill on Scroll

A large serif paragraph whose words fill from a muted ink to full text colour as each one crosses the viewport centre, scrubbed by scroll — plus a marker-style accent highlight that draws itself behind two key phrases.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1247 words of exact values — rewritten live as you tune the Playground.

prompt.md · Text Highlight Fill on Scroll
### EFFECT: Text Highlight Fill on Scroll
**What it does (visual):** After a full-height intro ("Read it with me"), the user scrolls into a wide block of large, light serif text — five or six lines of a studio manifesto — rendered in a dim ink (about 24% of the text colour mixed into the background — roughly `#3a3a3a` on a near-black page at 24%). As the paragraph travels up the screen, its words brighten one after another to the full text colour: the brightening front sits roughly on a reading line 50% down the viewport, so words above it read as "read" and words below are still ink. The fill is scrubbed — it follows the scrollbar in both directions with a 0.4s smoothing lag, and scrolling back up drains the ink out again. Two short phrases ("never move", "one honest idea") are wrapped as marks: when each one reaches the reading line a translucent highlighter swatch (#d4ff3a at 0.32 opacity) draws itself left-to-right behind the words over about 20% of viewport height of scroll, like a marker pen stroke, and un-draws in reverse. A mono eyebrow and a signature line frame the paragraph; a normal outro section follows.

**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 variable serif (demo: Fraunces 300, `opsz 144`), a mono for the eyebrow.

**HTML structure:**
```html
<section class="hl-intro"><p class="hl-intro__label">Scroll · Reading line</p><h1 class="hl-intro__title">Read it<br>with me</h1></section>
<section class="hl">
  <p class="hl__eyebrow">Studio manifesto · 01</p>
  <p class="hl__text">We build slowly on purpose. Every project begins as a conversation about what should <mark class="hl__mark">never move</mark>, and only then about what should. We measure a page by how it feels at the second visit, not the first, and we would rather ship <mark class="hl__mark">one honest idea</mark> than ten clever ones. The motion you notice is the motion we got wrong.</p>
  <p class="hl__sign">— Atelier Nord, founding note</p>
</section>
<section class="hl-outro"><p class="hl-outro__text">Scroll back up — the ink drains out of the words in reverse.</p></section>
```

**CSS requirements:**
- `.hl-intro`, `.hl-outro`: `min-height: 100vh; display: grid; place-content: center; padding: 6vw;` Intro title: display serif, `font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); letter-spacing: -0.04em; line-height: .9;`
- `.hl`: `padding: 20vh 6vw 30vh; max-width: 1200px; margin-inline: auto;`
- `.hl__text`: `position: relative; isolation: isolate; font-family: var(--font-display); font-weight: 300; font-size: clamp(1.75rem, 4.4vw, 4.25rem); line-height: 1.18; letter-spacing: -0.03em; text-wrap: pretty; color: color-mix(in srgb, var(--text) 24%, var(--bg));` — the paragraph's own `color` is the ink every word starts from; SplitText's word spans inherit it.
- `.hl__text .hl__word` (SplitText `wordsClass`): `display: inline-block; will-change: color;`
- `.hl__mark`: `position: relative; white-space: nowrap; background: none; color: inherit; --ink: 0;`
- `.hl__mark::before`: `content: ''; position: absolute; inset: .12em -.12em .02em; z-index: -1; border-radius: .08em; background: var(--accent, #d4ff3a); opacity: 0.32; transform: scaleX(var(--ink)); transform-origin: 0 50%;` (`isolation: isolate` on the paragraph keeps `z-index: -1` behind the words but above the page background).
- `.hl__eyebrow`, `.hl__sign`: mono, 11px, uppercase, `letter-spacing: .14em`, muted; sign has `margin-top: 3rem`.
- Light theme: tokens flip via `[data-theme="light"]`; the ink mixes against the light background automatically.

**Animation spec (exact values):**
1. Constants: `START = 'top 80%'`, `END = 'bottom 50%'`, `SCRUB = 0.4`, `STAGGER = 0.05`, `WORD_DURATION = 0.6`, `INK = 24%`.
2. Resolve the colours once as plain `rgb()` strings: parse `--text` and `--bg` from `getComputedStyle(document.documentElement)`; `textColor = rgb(text)`, `inkColor = rgb(bg + (text − bg) × 24 / 100)` (the same 24% mix the CSS uses). GSAP cannot interpolate from a computed `color-mix()`/`color(srgb …)` value, so always use `fromTo` with the explicit ink colour and never tween to the string `var(--text)`.
3. After `document.fonts.ready`: `const split = SplitText.create('.hl__text', { type: 'words', wordsClass: 'hl__word', aria: 'auto' })`. Nested `<mark>` elements are preserved; their words are split in place.
4. Word fill — one staggered tween, scrubbed:
   ```js
   gsap.fromTo(split.words, { color: inkColor }, {
     color: textColor, duration: 0.6, stagger: 0.05, ease: 'none',
     scrollTrigger: { trigger: '.hl__text', start: 'top 80%', end: 'bottom 50%', scrub: 0.4, markers: false }
   });
   ```
   Target: every word span; `color` from `inkColor` → `textColor`; because the tween is scrubbed, `duration`/`stagger` are relative weights only: total timeline = `0.6 + 0.05 × (words − 1)`, mapped linearly onto the scroll range from "paragraph top at 80% of the viewport" to "paragraph bottom at 50%". With ~12 words per line each word's fill spans ≈ one line of travel, which puts the bright front on the reading line.
5. Marker swatches — per `.hl__mark`:
   ```js
   gsap.to(mark, { '--ink': 1, ease: 'none',
     scrollTrigger: { trigger: mark, start: 'top 65%', end: 'top 45%', scrub: true, markers: false } });
   ```
   Property `--ink` (CSS custom property) `0 → 1`, which drives `transform: scaleX(var(--ink))` on the pseudo-element from the left edge.
6. Intro: `gsap.from('.hl-intro__title', { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.1 })`.
7. Call `ScrollTrigger.refresh()` after the split so trigger positions account for the final layout. Observe `data-theme` on `<html>` with a `MutationObserver`; on change, `ctx.revert()` (also reverts the split) and rebuild so the fill targets the new `--text`.

**Interaction:** None beyond scrolling. Wheel, trackpad, keyboard and scrollbar dragging all drive the fill; it is fully reversible.

**Responsive behavior:** Type is fluid via `clamp()`; the paragraph reflows and SplitText words simply wrap. Under 768px (`gsap.matchMedia()` `(max-width: 767px)`) move the swatch triggers to `start: 'top 70%', end: 'top 50%'` so phrases ink slightly earlier on a short viewport; everything else is identical. Highlight phrases stay on one line because of `white-space: nowrap` — keep them to 2–3 words so they fit at 375px.

**Accessibility:** With `prefers-reduced-motion: reduce` nothing is split or scrubbed: the paragraph is set to the full text colour and every mark gets `--ink: 1` (fully highlighted) — the same end state, static. `aria: 'auto'` on SplitText keeps the paragraph readable as a single string by screen readers. `<mark>` is semantic emphasis, so assistive tech announces the highlighted phrases.

**Performance rules:** Only `color` (on ≤ 60 small inline-blocks) and a `transform` on two pseudo-elements change — no layout, no paint of large areas. `will-change: color` on the words only. All tweens live in `gsap.context()` and are reverted on unmount (which also reverts the SplitText DOM). Use a single staggered tween, not one ScrollTrigger per word.

**Customization knobs:**
- Ink level: the `24%` in `color-mix(in srgb, var(--text) 24%, var(--bg))` (use 18% for a deeper ink, 35% for a softer one).
- `START` / `END` (`'bottom 50%'`) — move the reading line (`'top 70%'` / `'bottom 40%'` fills higher on screen).
- `SCRUB` (0.4) — 0 for 1:1 raw scrub, 0.8 for a lazier fill.
- `WORD_DURATION` (0.6) relative to `STAGGER` (0.05) — a larger ratio softens the front (more words partly lit at once).
- Swatch opacity (`0.32`) and colour (`#d4ff3a`); the swatch range (`top 65%` → `top 45%`).

**Acceptance criteria:**
- [ ] Before scrolling, the whole paragraph is dim ink and both swatches have zero width.
- [ ] As the paragraph scrolls, words brighten in reading order; the boundary between bright and dim sits near the vertical centre of the viewport.
- [ ] Each swatch draws left → right while its phrase crosses the centre and reverses on scroll-up.
- [ ] Nothing jumps when the text is split (split happens after fonts load) or when the viewport resizes.
- [ ] Reduced-motion users see a fully bright paragraph with both phrases highlighted.
- [ ] No console errors.

Source

Full source code

<section class="hl-intro">
  <p class="hl-intro__label">Scroll · Reading line</p>
  <h1 class="hl-intro__title">Read it<br>with me</h1>
</section>

<section class="hl">
  <p class="hl__eyebrow">Studio manifesto · 01</p>
  <p class="hl__text">We build slowly on purpose. Every project begins as a conversation about what should <mark class="hl__mark">never move</mark>, and only then about what should. We measure a page by how it feels at the second visit, not the first, and we would rather ship <mark class="hl__mark">one honest idea</mark> than ten clever ones. The motion you notice is the motion we got wrong.</p>
  <p class="hl__sign">— Atelier Nord, founding note</p>
</section>

<section class="hl-outro">
  <p class="hl-outro__text">Scroll back up — the ink drains out of the words in reverse.</p>
</section>