Text Reveal on Scroll Word by Word

A dark panel with one large serif statement pinned in place. The words start dim, and as the reader scrolls they brighten one after another, so the pace of reading is tied to the scroll wheel. A thin progress line on the right fills at the same rate and a few chosen words turn amber once lit. The panel scrolls inside its own frame so it can be previewed anywhere, and it scrolls itself until the visitor takes over. On phones the type drops in size and wraps to more lines.

Code

// Adapted from Magic UI (https://magicui.design/docs/components/text-reveal)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
import type { MotionValue } from "framer-motion";

/** The statement that lights up as the reader scrolls. */
const statement =
  "We build chairs the slow way. Each one starts as a board of air-dried oak, is shaped by two people at one bench, and leaves the workshop only when it sits right. Buy it once. Hand it down.";

/** Words to tint with the accent colour once they are lit. */
const accentWords = ["slow", "oak,", "once."];

/** Opacity of words that have not been reached yet. */
const DIM = 0.16;

function Word({ children, progress, range, accent }: { children: string; progress: MotionValue<number>; range: [number, number]; accent: boolean }) {
  const opacity = useTransform(progress, range, [DIM, 1]);
  return (
    <motion.span style={{ opacity }} className={`mr-[0.28em] inline-block ${accent ? "text-amber-400" : ""}`}>
      {children}
    </motion.span>
  );
}

export default function TextRevealOnScrollWordByWord() {
  const reduce = useReducedMotion();
  const scroller = useRef<HTMLDivElement>(null);
  const raw = useMotionValue(0);
  const progress = useSpring(raw, { stiffness: 120, damping: 26, mass: 0.4 });
  const barScale = useTransform(progress, [0, 1], [0, 1]);
  const hintOpacity = useTransform(progress, [0, 0.12], [1, 0]);
  const words = statement.split(" ");

  useEffect(() => {
    const el = scroller.current;
    if (!el) return;
    const max = () => Math.max(el.scrollHeight - el.clientHeight, 1);
    const onScroll = () => raw.set(el.scrollTop / max());
    el.addEventListener("scroll", onScroll, { passive: true });

    // Reduced motion: no self-scrolling. The text is readable dimmed and lights up when scrolled by hand.
    if (reduce) return () => el.removeEventListener("scroll", onScroll);

    // Demo: scroll down and back by itself until the visitor takes over.
    let frame = 0;
    let auto = true;
    let visible = true;
    const started = performance.now();
    const loop = (now: number) => {
      if (!auto) return;
      frame = requestAnimationFrame(loop);
      if (!visible || document.hidden) return;
      const t = ((now - started) % 9000) / 9000;
      // Down for 60% of the cycle, hold, then glide back.
      const p = t < 0.6 ? t / 0.6 : t < 0.75 ? 1 : 1 - (t - 0.75) / 0.25;
      el.scrollTop = (p * p * (3 - 2 * p)) * max();
    };
    const takeOver = () => {
      auto = false;
      cancelAnimationFrame(frame);
    };
    const io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; });
    io.observe(el);
    frame = requestAnimationFrame(loop);
    const events = ["wheel", "touchstart", "pointerdown", "keydown"] as const;
    events.forEach((name) => el.addEventListener(name, takeOver, { passive: true }));
    return () => {
      takeOver();
      io.disconnect();
      el.removeEventListener("scroll", onScroll);
      events.forEach((name) => el.removeEventListener(name, takeOver));
    };
  }, [raw, reduce]);

  return (
    <section className="bg-stone-200 px-4 py-10 sm:px-6 sm:py-14">
      <div className="relative mx-auto max-w-5xl overflow-hidden rounded-[28px] bg-[#16130f] text-stone-100 shadow-2xl shadow-stone-900/30">
        {/* Fixed chrome over the scrolling area. */}
        <div className="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-between px-6 py-5 text-xs font-medium uppercase tracking-widest text-stone-400 sm:px-10">
          <span>Halden &amp; Row, chairmakers</span>
          <span>Est. 1987</span>
        </div>
        <div aria-hidden="true" className="pointer-events-none absolute bottom-0 right-5 top-0 z-10 flex items-center sm:right-8">
          <div className="h-40 w-0.5 overflow-hidden rounded-full bg-stone-100/15">
            <motion.div className="h-full w-full origin-top rounded-full bg-amber-400" style={{ scaleY: barScale }} />
          </div>
        </div>
        <motion.p aria-hidden="true" style={{ opacity: hintOpacity }} className="pointer-events-none absolute inset-x-0 bottom-5 z-10 text-center text-xs font-medium uppercase tracking-widest text-stone-400">
          Scroll to read
        </motion.p>

        <div
          ref={scroller}
          tabIndex={0}
          role="region"
          aria-label="Our approach. Scroll to reveal the text."
          className="h-[520px] overflow-y-auto overscroll-contain focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-amber-400 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
        >
          {/* Tall track: the extra height is the scroll distance the reveal is spread over. */}
          <div className="h-[260%]">
            <div className="sticky top-0 flex h-[520px] items-center px-6 pr-12 sm:px-14 sm:pr-20">
              <p className="max-w-3xl font-serif text-[1.7rem] leading-[1.25] tracking-tight sm:text-5xl sm:leading-[1.18]">
                {words.map((word, i) => {
                  // Finish a little before the end so the last words are lit while there is still track left.
                  const start = (i / words.length) * 0.88;
                  const end = start + (1 / words.length) * 0.88 * 2.5;
                  return (
                    <Word key={i} progress={progress} range={[start, Math.min(end, 1)]} accent={accentWords.includes(word)}>
                      {word}
                    </Word>
                  );
                })}
              </p>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/text-reveal-on-scroll-word-by-word.json

When to use it

How to customize it

Accessibility

Questions

How does the scroll reveal work?

The frame holds a track much taller than itself, and the paragraph is `sticky` inside it, so the text stays put while the track scrolls past. The scroll position is turned into a number from 0 to 1. Each word owns a small slice of that range and maps it to its own opacity.

Why does it scroll by itself in the demo?

A preview that only reacts to scrolling would look still. A small loop moves the scroll position down and back until the visitor uses the wheel, touches, clicks or presses a key inside the frame, and then it stops for good. Delete the loop in the effect if you do not want it.

Why is the progress smoothed with a spring?

Mouse wheels scroll in jumps. Passing the raw position through a spring makes the words brighten in a steady flow instead of flickering on in batches.

Will it work with a very long text?

It works, but each word gets a smaller share of the scroll and the paragraph must still fit in the pinned area. Around 25 to 45 words is the comfortable range. For more, split it into two panels.

Can I reveal by line or by letter instead?

Yes. Split the string differently before the `map`: by sentence for lines, or by character for letters. Keep spaces as part of the pieces so the wrapping stays natural.

Adapted from Text Reveal in Magic UI, used under its open-source licence. Licences and credits

Related: Word by Word Blur In Paragraph Reveal, Sticky Scroll Storytelling with Pinned Visual, Reading Progress Bar with Section Reveals.

More scroll effects or all free UI components. Start for free to have a full site built around it.

More in Scroll Effects

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap