Anatomy of Scroll Text Reveal
How one scrollYProgress is sliced across words, scrubbed into opacity and blur, and optionally latched so revealed segments never re-dim.
ScrollTextReveal isn't a staggered entrance. It's a scrubbed mapping: the
paragraph's position in the viewport becomes one scrollYProgress, that
number is carved into equal slices, and each slice drives a segment from
dim to sharp. Scroll back up and — unless you latch — the sentence dims
in reverse.
One progress, n slices
useScroll watches the root element with offset
["start 0.85", "end 0.35"] — progress starts when the top hits ~85% of
the viewport and finishes when the bottom clears ~35%. The copy is split
by word (default), character, or line. Segment i of n owns
[i/n, (i+1)/n].
scrollYProgress ≈ 0.5
- Dim
- reveal 0 — opacity dimOpacity, blur 6px
- Lit
- reveal 1 — opacity 1, blur 0
- Slice
- segment i owns progress [i/n, (i+1)/n]
const { scrollYProgress } = useScroll({
target: localRef,
offset: offset ?? ["start 0.85", "end 0.35"],
});
segments.map(({ key, segment }, i) => (
<RevealSegment
key={key}
progress={scrollYProgress}
start={i / n}
end={(i + 1) / n}
// …
/>
))At mid-progress the leading slices are already at reveal 1, the active
slice is interpolating, and trailing slices still sit at dimOpacity
(default 0.15) with optional blur(6px).
The scrub
Each segment maps its slice onto a 0→1 reveal factor, then onto opacity
(dimOpacity → 1) and, when blur is on, blur(6px) → blur(0).
Because those transforms read the live progress value, scrolling up
rewinds them — no timeline to cancel, no play-once flag.
scrollYProgress
- Progress
- scrollYProgress 0 → 1 → 0
- Opacity
- dimOpacity → 1 per slice
- Blur
- blur(6px) → blur(0) in sync
const raw = useTransform(progress, [start, end], [0, 1]);
const opacity = useTransform(reveal, [0, 1], [dimOpacity, 1]);
const filter = useTransform(reveal, [0, 1], ["blur(6px)", "blur(0px)"]);Only compositor props move. Layout stays put; whitespace tokens keep
whitespace-pre so the original spacing survives the split.
keepRevealed
Default scrub is honest: leave the paragraph and the words re-dim. Set
keepRevealed and each segment latches at its peak —
reveal.set(keepRevealed ? Math.max(reveal.get(), v) : v);— so scroll-up never undoes a word that already landed. Same progress driver, two exit behaviours.
default scrub
keepRevealed
- Scrub
- reveal tracks raw progress — dims on scroll-up
- Latch
- Math.max(prev, v) — stays lit once revealed
Accessibility
Assistive tech never walks the dimmed segments. The root gets
aria-label with the full string; a visually hidden sr-only span
repeats the copy; every animated segment is aria-hidden. Under
prefers-reduced-motion the component skips useScroll entirely and
renders the plain string — fully legible, no scrub coupling.
if (reduceMotion) {
return React.createElement(Component, { ref, className, ...props }, text);
}
// …
<span className="sr-only …">{text}</span>
{segments.map(/* RevealSegment aria-hidden */)}The result
One progress value, equal slices, opacity and blur scrubbed to the scroll — latch optional. Scroll the page through the panel below.
Great interfaces read like a sentence — one idea resolving into the next. As you scroll, each word settles into focus, pacing attention exactly where it belongs.
With keepRevealed, each word latches at full presence once it lands — so the paragraph stays lit as you scroll back up instead of dimming again.