ExamplesTextMotion UI: Scroll word reveal

Scroll word reveal

An example of revealing text word by word across a scroll range with useScroll and useTransform in Motion for React.

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>
  )
}

Related examples

Latest in React