Scroll Text Reveal

Words light up one after another as you scroll, so a statement is read at the pace the reader moves.

  • GSAP
  • Text
  • Scroll
  • Added
Scroll ↓

AI prompt

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

Demo.tsx
'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>
  )
}
demo.module.css
.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; }

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.