Scroll word reveal
An example of revealing text word by word across a scroll range with useScroll and useTransform in Motion for React.
Source code
"use client"
import {
motion,
useReducedMotion,
useScroll,
useTransform,
type MotionValue,
} from "motion/react"
import { Fragment, useRef } from "react"
import "./styles.css"
const STATEMENT =
"Animation should never make you wait. It should reveal the next idea at exactly the moment you are ready to read it."
const START_OPACITY = 0.15
const SPREAD = 0.8
const WORD_DURATION = 0.2
export interface WordProgressRange {
start: number
end: number
}
function getWordProgressRange(
index: number,
count: number,
): WordProgressRange {
const start = count <= 1 ? 0 : (index / (count - 1)) * SPREAD
return {
start,
end: Math.min(1, start + WORD_DURATION),
}
}
export function getWordOpacity(
progress: number,
{ start, end }: WordProgressRange,
startOpacity = START_OPACITY,
): number {
if (progress <= start) return startOpacity
if (progress >= end) return 1
const wordProgress = (progress - start) / (end - start)
return startOpacity + (1 - startOpacity) * wordProgress
}
function Word({
children,
progress,
index,
count,
reducedMotion,
}: {
children: string
progress: MotionValue<number>
index: number
count: number
reducedMotion: boolean
}) {
const range = getWordProgressRange(index, count)
const opacity = useTransform(progress, (latest) =>
getWordOpacity(latest, range),
)
return (
<motion.span
aria-hidden="true"
style={reducedMotion ? undefined : { opacity }}
>
{children}
</motion.span>
)
}
/*
* SCROLL STORYBOARD
*
* 0% the first word begins to brighten
* 80% word starts have travelled through the complete statement
* 100% the final word settles and the full thought is readable
*/
export default function TextScrollWordReveal() {
const sectionRef = useRef<HTMLElement>(null)
const reducedMotion = useReducedMotion()
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start start", "end end"],
})
const words = STATEMENT.split(" ")
return (
<section
ref={sectionRef}
className="scroll-word-reveal"
aria-labelledby="scroll-word-reveal-heading"
>
<div className="scroll-word-reveal__stage">
<div className="scroll-word-reveal__layout">
<div className="scroll-word-reveal__progress" aria-hidden="true">
<motion.span
style={{ scaleY: reducedMotion ? 1 : scrollYProgress }}
/>
</div>
<div className="scroll-word-reveal__content">
<p className="scroll-word-reveal__kicker">Scroll to reveal</p>
<h1
id="scroll-word-reveal-heading"
className="scroll-word-reveal__heading"
aria-label={STATEMENT}
>
{words.map((word, index) => (
<Fragment key={`${word}-${index}`}>
<Word
progress={scrollYProgress}
index={index}
count={words.length}
reducedMotion={Boolean(reducedMotion)}
>
{word}
</Word>
{index < words.length - 1 ? " " : null}
</Fragment>
))}
</h1>
</div>
</div>
</div>
</section>
)
}







