Scroll Text Reveal
Words light up one after another as you scroll, so a statement is read at the pace the reader moves.
Create a scroll-scrubbed text reveal with GSAP ScrollTrigger. Split the paragraph into word spans (one span per character for languages without spaces) and set them to opacity 0.15. Register ScrollTrigger and tween the spans to opacity 1 with stagger 0.1 and ease "none", with scrollTrigger { trigger: the paragraph, start: "top 80%", end: "bottom 45%", scrub: true }, so each word brightens in reading order as the user scrolls and dims again when scrolling back. If the text sits inside a scrolling panel rather than the page, pass that element as the scroller. Use a large display font. Put the full sentence in aria-label on the paragraph and hide the spans from screen readers. Under prefers-reduced-motion, show all words at full opacity. Revert the GSAP context on unmount. The text reads "Motion should explain what changed, guide the eye, and then get out of the way."
Source
'use client'
import { useEffect, useRef } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import s from './demo.module.css'
gsap.registerPlugin(ScrollTrigger)
export default function ScrollTextReveal({ labels }: { labels: Record<string, string> }) {
const frame = useRef<HTMLDivElement>(null)
const text = useRef<HTMLParagraphElement>(null)
const spaced = labels.text.includes(' ')
const words = spaced ? labels.text.split(' ') : Array.from(labels.text)
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const ctx = gsap.context(() => {
gsap.fromTo(
`.${s.word}`,
{ opacity: 0.15 },
{
opacity: 1,
stagger: 0.1,
ease: 'none',
scrollTrigger: { trigger: text.current, scroller: frame.current, start: 'top 80%', end: 'bottom 45%', scrub: true },
},
)
}, frame)
return () => ctx.revert()
}, [labels.text])
return (
<div ref={frame} className={s.frame} tabIndex={0}>
<span className={s.hint}>{labels.hint} ↓</span>
<p ref={text} className={s.text} aria-label={labels.text}>
{words.map((w, i) => (
<span key={i} className={s.word} aria-hidden="true">
{w}
{spaced && i < words.length - 1 ? ' ' : ''}
</span>
))}
</p>
</div>
)
}
.frame {
position: absolute;
inset: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.frame:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -4px; }
.hint {
display: block;
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
text-align: center;
color: var(--stage-muted);
}
.frame { padding: 24px 24px 0; }
.text {
max-width: 300px;
margin: 220px auto 200px;
font: 600 var(--text-xl) / 1.35 var(--font-display);
color: var(--stage-ink);
}
.word { display: inline; }