---
name: scroll-text-mask
description: Use when you want "Editorial, polished, creative" - Reveals text through masks or clipped containers during scroll.
---

# Scroll Text Mask

> **Category:** Scroll  -  **Personality:** Editorial, polished, creative
>
> **Best use:** Headlines, storytelling
>
> **Ratings:** Professional 4/5 - Casual 3/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

The editorial **"highlight as you read"** effect: a passage is **pinned** in place and its words fill in one after another — from a faint paper tone to full ink — as the reader scrolls. Crucially it is scroll-**scrubbed**, not an entrance animation: the reveal is tied frame-for-frame to scroll progress, so it runs forward *and* backward as you drag the scrollbar. That deliberate, word-by-word pacing is why it suits ledes, manifestos and story openings. (Keep it distinct from *Text Reveal Mask*, which plays once on entry and is done.)

## Dependencies / CDN

**None** — the fill is pure CSS (`color-mix` + a CSS custom property) and the scroll wiring is ~30 lines of vanilla JS. The demo only pulls two display fonts, which are optional:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Spectral:ital,wght@0,500;0,700;1,500&display=swap" rel="stylesheet">
```

## HTML

A tall **scroll track** whose **sticky pin** holds the composition. The passage is real text (JS wraps each word at runtime):

```html
<div class="stm-stage">
  <!-- THE scroll context. tabindex+role make the contained scroller keyboard-reachable -->
  <div class="stm-scroll" tabindex="0" role="region" aria-label="Scroll to reveal the passage">
    <div class="stm-track">                  <!-- taller than the viewport => scroll travel -->
      <article class="stm-pin">              <!-- pinned while you scroll -->
        <p class="stm-lede" id="stmLede">We have forgotten how to wait. A kiln keeps its own slow
          hours, and the hand that feeds it must learn them too &mdash; every vessel worth keeping
          is shaped by patience, repetition, and the quiet faith that attention is its own reward.</p>
        <div class="stm-progress"><span id="stmBar"></span></div>
      </article>
    </div>
  </div>
</div>
```

## CSS

```css
.stm-stage{
  /* derive the scroller height AND the pin height from ONE var so they match exactly */
  --stm-h: clamp(470px, 80vh, 620px);
  --stm-ink:#241f17;       /* a revealed (read) word            */
  --stm-faint:#c8bca2;     /* a not-yet-revealed word           */
  position:relative; border-radius:24px; overflow:hidden;
}
.stm-scroll{ height:var(--stm-h); overflow-y:auto; }       /* the scroll context */
.stm-track{ height:calc(var(--stm-h) * 2.65); }            /* bigger multiplier = slower reveal */
.stm-pin{ position:sticky; top:0; height:var(--stm-h); }   /* stays put; only colours change */

/* THE REVEAL — one declaration. Each word's fill is driven by its own --f (0 → 1). */
.stm-w{ color:var(--stm-ink); transition:color .16s linear; }   /* fallback = fully readable */
.stm-live .stm-w{
  color: color-mix(in oklab, var(--stm-ink) calc(var(--f,0) * 100%), var(--stm-faint));
}

/* optional scrubbed progress bar */
.stm-progress span{ display:block; height:3px; width:0;
  background:linear-gradient(90deg, #b8482a, #d97a3e); transition:width .12s linear; }
```

The `.stm-live` class is added by JS *only* when motion is allowed. Without it (no-JS, reduced-motion, or a browser lacking `color-mix`) the words fall back to `--stm-ink` and the passage is simply fully readable.

## JavaScript

```js
(function(){
  var stage = document.querySelector('.stm-stage'),
      scroller = document.querySelector('.stm-scroll'),
      lede = document.getElementById('stmLede'),
      bar = document.getElementById('stmBar');
  if(!stage || !scroller || !lede) return;

  // 1) Wrap every word in its own <span class="stm-w"> (kept as real text for screen readers).
  var tokens = lede.textContent.trim().split(/\s+/);
  lede.textContent = '';
  var words = tokens.map(function(tok, i){
    var s = document.createElement('span');
    s.className = 'stm-w'; s.textContent = tok;
    lede.appendChild(s);
    if(i < tokens.length - 1) lede.appendChild(document.createTextNode(' '));
    return s;
  });

  // 2) Reduced motion => show everything, no scrubbing.
  if(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches){
    if(bar) bar.style.width = '100%';
    return;
  }
  stage.classList.add('stm-live');

  var N = words.length, SOFT = 3.4, LEAD = 0.9, ticking = false;
  function update(){
    ticking = false;
    var max = scroller.scrollHeight - scroller.clientHeight;   // total scrollable distance
    var p = max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0;  // progress 0..1
    var head = p * (N - 1 + SOFT - LEAD) + LEAD;               // the "fill head", in word-units
    for(var i = 0; i < N; i++){
      var f = (head - i) / SOFT;                               // soft leading edge over SOFT words
      words[i].style.setProperty('--f', (f < 0 ? 0 : f > 1 ? 1 : f).toFixed(3));
    }
    if(bar) bar.style.width = (p * 100).toFixed(1) + '%';
  }
  function onScroll(){ if(!ticking){ ticking = true; requestAnimationFrame(update); } }

  scroller.addEventListener('scroll', onScroll, {passive:true});
  window.addEventListener('resize', onScroll);
  update();   // initial paint: passage sits faint, ready to be read
})();
```

## How it works

- **Pin + tall track.** `position:sticky; top:0` keeps the passage fixed inside its scroll container while a sibling track (here `2.65×` the viewport height) provides the scroll distance. Nothing *moves* — only colour changes — which is what reads as "highlighting".
- **Scroll progress 0→1.** `p = scrollTop / (scrollHeight - clientHeight)` of the scroll container. Reading the *container's* `scrollTop` (not `window.scrollY`) is what makes the demo self-contained inside the catalog.
- **Per-word fill head.** `head = p·(N-1+SOFT-LEAD) + LEAD` is the leading edge expressed in word units. Each word `i` gets `f = clamp((head - i) / SOFT, 0, 1)`. `SOFT` spreads the edge over a few words so it wipes softly instead of snapping; `LEAD` keeps the first word gently lit at rest so the opening invites a scroll. The mapping is tuned so the last word reaches `f = 1` exactly at `p = 1`.
- **The colour.** `color-mix(in oklab, ink f%, faint)` interpolates each word between the faint paper tone and full ink in perceptual `oklab` space. The reveal is therefore just a number (`--f`) per span — no clip-paths, no canvas.

## Customization

- **Reveal speed / length:** change the `.stm-track` multiplier (`2.65`) — larger = slower, more deliberate.
- **Edge feel:** `SOFT` (≈1 = crisp word-by-word snap, ≈5 = a long velvety gradient). `LEAD` sets how much is pre-lit at rest.
- **Palette:** swap `--stm-ink` / `--stm-faint`. On dark themes use e.g. faint `#3a3a42` → ink `#f4efe6`. The accent (bar, drop-cap) is independent.
- **Use the page scroll instead of a box:** drop the inner scroller and compute `p` from the pinned section's `getBoundingClientRect()` against the viewport — same `head`/`--f` maths.
- **No `color-mix`?** Drive `opacity` from `--f` instead: `.stm-live .stm-w{ opacity:calc(.2 + .8*var(--f,0)) }`.
- **Truer "mask":** for a single headline, clip a bright copy over a faint one with `clip-path:inset(0 calc((1 - var(--f))*100%) 0 0)` driven by the same progress.

## Accessibility & performance

- **The text is always real and complete in the DOM** — wrapping words in spans changes nothing for screen readers, so unread/faint words are still announced in full.
- **Reduced motion = fully shown.** `prefers-reduced-motion: reduce` short-circuits the JS (no `.stm-live`, no scroll listener) and the CSS fallback paints every word at full ink.
- **Cheap to animate:** only `color` (or `opacity`) changes — never layout. Don't vary `font-weight` per word: it reflows and the line jitters.
- **Throttled:** scroll work is batched into one `requestAnimationFrame`; setting a CSS variable on ~40 spans per frame is trivial.
- A contained scroller gets `tabindex="0"` + `role="region"` + an `aria-label` so keyboard users can focus and arrow-scroll it.

## Gotchas

- **Scrubbed, not played.** The whole point is that progress maps to scroll position both ways. If you fire it once with `IntersectionObserver`, you've built a different effect (an entrance reveal) — see *Text Reveal Mask*.
- **Sticky needs headroom.** The pin must be *shorter than or equal to* its scroll viewport, with a taller sibling track to scroll through. Deriving the scroller and pin heights from one `--stm-h` var keeps them locked together; mismatched percentages are the usual reason a pin "won't stick".
- **Read the right scroll value.** Inside a contained scroller use `element.scrollTop`; switching to the window later means switching to `scrollY` + `getBoundingClientRect()`.
- **Rebuild order.** Splitting words wipes `textContent`, so append any decorative children (a drop-cap is fine via `::first-letter`, but an end-mark element) *after* the wrap loop, not before — otherwise they get deleted.
- **Drop-cap specificity.** `::first-letter` can be out-specified by `.stm-live .stm-w`; qualify it (`.stm-stage .stm-lede::first-letter`) if you want the cap to stay a fixed colour while the rest reveals.
- **`color-mix` support.** It needs a 2023+ engine; always keep the plain-ink fallback rule so older browsers degrade to readable static text.
