Skip to contact form
← BlogEngineeringSeptember 13, 2026 · 3 min read

Text that develops like film

The scroll-scrubbed paragraph on our homepage, and why it's driven by scroll position instead of time.

he easy version of a text reveal fades a paragraph in once, on a timer, the moment it enters the viewport. It looks fine the first time and wrong every time after — scroll back up past it and the words are just sitting there, already finished, because a setTimeout doesn't know or care where your scroll position actually is. We wanted something that replays correctly no matter which direction you're moving.

Keyedtoscrollpositioninsteadofaclock,scrubbingupanddownreplaysthereveallikerewindingfilm.

One motion value per word

typescript

const { scrollYProgress } = useScroll({ target: ref, offset: ['start 0.9', 'start 0.35'], }); const words = text.split(' '); // each word gets its own slice of the 0→1 scroll range words.map((word, i) => ( <ScrubWord word={word} progress={scrollYProgress} range={[i / words.length, (i + 1) / words.length]} /> ));

Each word is handed the same scroll-position value but a different slice of it to react to — word one finishes sharpening well before word ten even starts. useTransform maps that slice onto opacity, a small blur, and a few pixels of upward drift, so the sentence reads as focusing into clarity rather than just fading in. Because it's all derived from scrollYProgress and nothing else, scrolling back up runs the whole thing in reverse for free — there's no separate 'exit' animation to write.

The one branch that matters

None of this runs for anyone with reduced motion turned on. The component checks useReducedMotion() once and, if it's set, renders the words as plain static text with none of the motion wiring attached — not just skipped visually, actually never subscribed to scroll events in the first place. An animation nobody asked for is a worse experience than no animation at all.