GodUIGodUI
81

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

Anatomyone progress, n equal 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}
    // …
  />
))

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

The scrubprogress drives every slice

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)"]);

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 —

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

keepRevealedlatch vs reverse on scroll-up

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.

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

Resultthe real component — scroll the page

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.

On this page

Built with GodUI

Beautifully crafted motion components for modern interfaces.

Star on GitHub