GodUIGodUI
129Follow on X

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 is 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 the sentence dims in reverse, unless you latch it.

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 (dimOpacity → 1) and, when blur is on, blur(6px) → blur(0). Because those transforms read the live progress value, scrolling up rewinds them. There is no timeline to cancel and 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, so it 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

By default the scrub is fully reversible: leave the paragraph and the words re-dim. Set keepRevealed and each segment latches at its peak, so scrolling up never undoes a word that already landed. One progress driver gives two exit behaviours.

04/04Result

Great interfaces read like a sentence, one idea leading into the next. As you scroll, each word settles into focus.

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, and opacity and blur scrubbed to the scroll, with an optional latch. 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 and not tied to scroll.

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 RevealSS: Compositor-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 →