Masked Scroll Highlight
Copy that resolves word by word as you scroll, mapped by real reading position rather than word index.
DOM + CSS0 deps1 file · 5 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef } from "react";
/**
* A block of copy that lights up word by word as it passes through the
* viewport.
*
* The part that is usually wrong is the mapping. Driving each word from
* `index / total` assumes every line holds the same number of words, so a
* ragged right edge or a short final line makes the highlight run ahead of the
* reader on some lines and behind on others. The position that matters is where
* the word sits IN READING ORDER ACROSS THE BLOCK — its line number plus how
* far along that line it starts.
*/
export function MaskedScrollHighlight({
text = "We design the parts people actually touch. The hinge, the handle, the moment a thing answers back. Everything else is decoration.",
feather = 1.1,
// ---------------------------------------------------------------
// Full source available with a viberdy Pro subscription.
// https://viberdy.dev/pricing
// ---------------------------------------------------------------
}Details
About this text effect
The scroll-linked reading highlight is a familiar effect with an unfamiliar amount of room to get wrong, and the mistake is almost always in the mapping. Driving each word from its index divided by the total quietly assumes that every line holds the same number of words; with a ragged right edge, or a short final line, the highlight runs ahead of the reader on some lines and behind on others, which is exactly the thing the effect exists to avoid. What matters is where a word sits in reading order across the block, so the words are measured, grouped into real lines by their rounded vertical position, and scored as their line number plus how far along that line they begin — then normalised by line count rather than word count. Two structural choices keep it robust. Progress comes from the section's own bounding rectangle rather than from a scroll position, so it behaves identically whether the page scrolls, an ancestor scrolls, or both, which is what lets it survive being dropped inside a modal or a split pane. And the animation frame loop is gated on an IntersectionObserver, because a scroll handler that fires for every block on a long page is how this pattern becomes the reason a site janks. Measurements are retaken once webfonts resolve, since metrics from the fallback face usually break lines somewhere else entirely.
Curator’s note
Map words by index and it desynchronises on every ragged line. Group them into real lines first and it tracks the eye.

