MynduiMyndui
0

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.

01/04One progress, n slices
0i/n → (i+1)/n1

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]
tsx
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}  // …/>))
01

One progress, n slices

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.

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

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

02/04The scrub

scrollYProgress

Progress
scrollYProgress 0 → 1 → 0
Opacity
dimOpacity → 1 per slice
Blur
blur(6px) → blur(0) in sync
tsx
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)"]);
02

The scrub

Each segment maps its slice onto a 0→1 reveal factor, then onto opacity (dimOpacity1) 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.

Only compositor props move. Layout stays put; whitespace tokens keep whitespace-pre so the original spacing survives the split.

03/04keepRevealed

default scrub

keepRevealed

Scrub
reveal tracks raw progress — dims on scroll-up
Latch
Math.max(prev, v) — stays lit once revealed
tsx
reveal.set(keepRevealed ? Math.max(reveal.get(), v) : v);
03

keepRevealed

Default scrub is honest: leave the paragraph and the words re-dim. Set keepRevealed and each segment latches at its peak — so scroll-up never undoes a word that already landed. Same progress driver, two exit behaviours.

04/04Result

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.

04

The result

One progress value, equal slices, opacity and blur scrubbed to the scroll — latch optional. Scroll the page through the panel below.

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.

tsx
 
if (reduceMotion) {
  return React.createElement(Component, { ref, className, ...props }, text);
}

// …
<span className="sr-only …">{text}</span>
{segments.map(/* RevealSegment aria-hidden */)}

Motion Score

Scroll Text RevealSSCompositor-only
SopacityFade / cross-fade
SfilterFilter (blur / brightness)
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →