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"

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

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

/**
 * ==============   Styles   ================
 */

function Stylesheet() {
  return (
    <style>{`
      .scroll-word-reveal {
        width: 100%;
        min-height: 220vh;
        overflow-x: clip;
        background: var(--background);
        color: var(--foreground);
        font-family: var(--font-sans);
        -webkit-font-smoothing: antialiased;
      }

      .scroll-word-reveal__stage {
        position: sticky;
        top: 0;
        width: 100%;
        min-height: 100vh;
        display: flex;
        align-items: center;
        overflow: hidden;
        padding: 64px 48px;
      }

      .scroll-word-reveal__layout {
        width: 100%;
        max-width: 1024px;
        display: grid;
        grid-template-columns: 1px minmax(0, 1fr);
        align-items: start;
        gap: 40px;
        margin: 0 auto;
      }

      .scroll-word-reveal__progress {
        position: relative;
        width: 1px;
        height: 112px;
        overflow: hidden;
        background: var(--border);
      }

      .scroll-word-reveal__progress span {
        position: absolute;
        inset: 0;
        display: block;
        background: var(--foreground);
        transform-origin: top;
      }

      .scroll-word-reveal__content {
        max-width: 900px;
      }

      .scroll-word-reveal__kicker {
        margin: 0 0 40px;
        color: var(--foreground-feint);
        font-family: var(--font-mono);
        font-size: 11px;
        font-variation-settings: "wght" 560;
        letter-spacing: 0.16em;
        line-height: 1;
        text-transform: uppercase;
      }

      .scroll-word-reveal__heading {
        max-width: 26ch;
        margin: 0;
        color: var(--foreground);
        font-family: var(--font-sans);
        font-size: clamp(36px, 5.4vw, 56px);
        font-variation-settings: "opsz" 56, "wght" 680;
        letter-spacing: -0.045em;
        line-height: 1.08;
        text-wrap: balance;
      }

      @media (max-width: 600px) {
        .scroll-word-reveal__stage {
          padding: 48px 24px;
        }

        .scroll-word-reveal__layout {
          grid-template-columns: 1fr;
          gap: 0;
        }

        .scroll-word-reveal__progress {
          display: none;
        }

        .scroll-word-reveal__kicker {
          margin-bottom: 28px;
        }
      }
    `}</style>
  )
}

Related examples

Latest in React