Skip to Content

TextReveal

A word-by-word reveal primitive. By default the reveal is scroll-driven; the words animate in as the section moves through the viewport. Pass progress and the component flips into a fully controlled mode where you own the value (perfect for sliders, programmatic playback, hero entrances, or anything that shouldn’t depend on scroll).

The component owns the per-word splitting and motion plumbing; you own the typography, the layout, and the per-word transform.

Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
40%
import { TextReveal } from "@togetheragency/ui/text-reveal"; import { useState } from "react"; export function InteractiveTextReveal() { const [progress, setProgress] = useState(40); return ( <> <TextReveal.Root progress={progress}> <TextReveal.Text className="text-3xl font-medium"> Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. </TextReveal.Text> </TextReveal.Root> <input type="range" min={0} max={100} value={progress} onChange={(e) => setProgress(Number(e.target.value))} /> </> ); }

Import

import { TextReveal } from "@togetheragency/ui/text-reveal";

The component is exported as a compound object; every sub-component is a property of TextReveal:

TextReveal.Root; TextReveal.Text; TextReveal.Word;

Composition

TextReveal.Root is the orchestrator. In its default scroll mode give it a tall height; the height determines how much scroll distance the reveal occupies — and place a sticky child inside to pin the words while the user scrolls past:

<TextReveal.Root className="relative min-h-[200vh]"> <div className="sticky top-0 flex h-screen items-center"> <TextReveal.Text className="text-4xl"> The quick brown fox jumps over the lazy dog. </TextReveal.Text> </div> </TextReveal.Root>

In controlled mode the layout is up to you; the Root is just a container that propagates progress to descendants via context; no height or sticky wrapper required.

  • TextReveal.Root wires up motion’s useScroll against itself (scroll mode) or normalizes the progress prop to a MotionValue (controlled mode). Either way, it exposes the resulting progress to descendants via context.
  • TextReveal.Text accepts a plain string, splits it on whitespace, and renders each word through TextReveal.Word. Use the transform prop to customise the reveal.
  • TextReveal.Word is the lower-level primitive; one animated word that reads its slice of the root progress. Compose words manually when you need arbitrary surrounding markup (links, highlights, mixed elements).

In scroll mode the reveal is driven by the root’s intersection with the viewport; not by any manual offset on TextReveal.Text. Place the text wherever you like inside the root (sticky, centered, in a grid cell) and the timing tracks the surrounding scroll container.

Controlled vs scroll

The progress prop on TextReveal.Root is the switch:

  • Omitted → scroll mode. useScroll runs internally, optionally scoped to a container ref and shaped by offset.
  • Provided (0100) → controlled mode. Scroll plumbing is bypassed entirely. Drive the value from a slider, a timeline, an IntersectionObserver, a test, a Storybook control — anything that produces a number.

The same per-word transforms work in both modes — internally progress is always normalized to 0–1, so useTransform(p, [0, 1], …) calls in your transform builders behave identically either way.

// Scroll mode (default) <TextReveal.Root className="min-h-[200vh]">…</TextReveal.Root> // Controlled mode <TextReveal.Root progress={42}>…</TextReveal.Root>

Usage

Custom intro & outro

TextReveal.Text and TextReveal.Word accept a transform prop — a function that receives a MotionValue<number> for the word’s local progress (0 → 1 while the word’s slice is active) and returns any MotionStyle.

Compose useTransform calls inside the function to drive opacity, translate, blur, scale, color — whatever your reveal needs. Because the function runs inside the word’s render, you can call hooks freely (just keep their order stable).

Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
60%
import { useTransform } from "motion/react"; <TextReveal.Text className="text-4xl" transform={(p) => ({ opacity: useTransform(p, [0, 0.5], [0, 1]), y: useTransform(p, [0, 1], [24, 0]), filter: useTransform(p, [0, 1], ["blur(10px)", "blur(0px)"]), })} > Lorem ipsum dolor sit amet… </TextReveal.Text>;

Color reveal

A classic “ink in” effect — let opacity stay at 1 and animate color between a muted shade and the foreground.

Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
50%
<TextReveal.Text className="text-4xl" transform={(p) => ({ color: useTransform( p, [0, 1], ["rgb(156 163 175 / 0.35)", "rgb(23 23 23)"], ), })} > Lorem ipsum dolor sit amet… </TextReveal.Text>

Composing with TextReveal.Word

When you need richer markup than a single string — inline links, highlighted spans, mixed elements — drop TextReveal.Text and render TextReveal.Word directly. Pass each word its index and the shared total so the slice math lines up.

Loremipsumdolorsitamet.

const words = ["Lorem", "ipsum", "dolor", "sit", "amet."]; <TextReveal.Root progress={65}> <p className="flex flex-wrap gap-x-3 gap-y-2 text-4xl"> {words.map((word, i) => ( <TextReveal.Word key={word} index={i} total={words.length} transform={(p) => ({ opacity: useTransform(p, [0, 0.6], [0.1, 1]), y: useTransform(p, [0, 1], [16, 0]), })} > {i === words.length - 1 ? ( <span className="rounded-md bg-foreground/10 px-2 py-0.5 font-mono"> {word} </span> ) : ( word )} </TextReveal.Word> ))} </p> </TextReveal.Root>;

Scroll-driven

Omit progress to fall back to the default scroll-driven behavior. Give the root some height — the example below uses a normal preview box, so the reveal animates as you scroll this docs page past it. In your own app you’d typically use min-h-[200vh] and a sticky inner wrapper.

This is a text
<TextReveal.Root className="relative min-h-[200vh]"> <div className="sticky top-0 flex h-screen items-center"> <TextReveal.Text className="text-4xl"> Lorem ipsum dolor sit amet consectetur adipiscing elit. </TextReveal.Text> </div> </TextReveal.Root>

Tuning the scroll trigger

By default the reveal starts the moment the section touches the viewport bottom and finishes the instant it leaves the top. That can feel too eager — words flicker as soon as the section appears and complete while the reader is still mid-sentence. startMargin and endMargin let you push those endpoints inward so the reveal only runs through the “comfortable” middle of the section’s pass through the viewport.

Both props accept either a number (percentage of the total scroll range, 0–100) or a CSS length string"100px", "20vh", "30dvh", "5rem", "15%", anything the browser understands. String values are measured at runtime, so any unit works.

Both default to 20 (i.e. 20% on each side), which gives a soft trigger out of the box.

Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
<TextReveal.Root startMargin="35vh" endMargin="35vh"> <TextReveal.Text className="text-4xl"> Lorem ipsum dolor sit amet consectetur adipiscing elit. </TextReveal.Text> </TextReveal.Root>

startMargin and endMargin are scroll-mode only. In controlled mode the consumer owns the value directly, so trimming the trigger range doesn’t apply.

Replayable vs one-shot

By default the reveal is reversible: in scroll mode, scrolling back up plays the outro; in controlled mode, lowering progress rewinds the reveal. Pass once to latch progress so it only ever increases — the outro never plays, regardless of mode.

<TextReveal.Root once progress={progress}> </TextReveal.Root>

Reduced motion

When the user has prefers-reduced-motion: reduce set, every word skips its transform and renders directly in the revealed state. No additional setup required — the component reads the media query via motion’s useReducedMotion hook.

API Reference

TextReveal.Root

Container that exposes a MotionValue<number> (range 0–1) to descendants via context. Renders a <div>. Operates in one of two modes depending on whether progress is provided.

PropTypeDefaultDescription
progressnumberWhen defined, switches to controlled mode. Percentage 0–100 (clamped) that drives the reveal directly. When omitted, the root runs in scroll mode.
oncebooleanfalseWhen true, latches progress so it only ever increases. The outro never plays. Honored in both modes.
offsetmotion’s ScrollOffset["start end", "end start"]Scroll mode only. Where the reveal starts and ends, relative to the viewport. Forwarded straight to useScroll. Ignored when progress is provided.
containerRefObject<HTMLElement | null>Scroll mode only. Optional scroll container to track instead of the window. Ignored when progress is provided.
startMarginnumber | string20Scroll mode only. Delays the start of the reveal. Number = percentage of the scroll range; string = any CSS length ("100px", "20vh", "30dvh", "5rem", …).
endMarginnumber | string20Scroll mode only. Advances the end of the reveal. Same value shape as startMargin.
classNamestringMerged via cn onto the underlying <div>. In scroll mode, give it a height (e.g. min-h-[200vh]) so there is scroll distance for the reveal.
...restReact.ComponentPropsWithoutRef<"div">Forwarded to the underlying <div>.

Data attributes: data-slot="text-reveal-root", data-mode="scroll" | "controlled", data-once (empty string when once, otherwise absent).

TextReveal.Text

Splits a plain-string child on whitespace and renders each word through TextReveal.Word. For richer composition use TextReveal.Word directly.

PropTypeDefaultDescription
childrenstringrequiredThe text to reveal. Must be a single string — no nested elements.
asElementType"span"Element rendered as the outer wrapper.
transform(localProgress: MotionValue<number>) => MotionStyle({ opacity: p })Per-word transform builder. Receives the word’s local progress (0 → 1) and returns any MotionStyle — opacity, translate, blur, color.
revealAtnumber0.5Local-progress threshold at which a word flips its data-state from "hidden" to "revealed". Visual reveal is governed by transform.
wordClassNamestringClass applied to every word <motion.span>. Handy for shared typography defaults.
classNamestringMerged via cn onto the outer element.
...restOmit<React.ComponentPropsWithoutRef<"span">, "children">Forwarded to the outer element.

Data attributes: data-slot="text-reveal-text".

TextReveal.Word

Lower-level primitive — one animated word. Renders a motion.span.

PropTypeDefaultDescription
indexnumberrequiredZero-based position of this word inside the overall reveal.
totalnumberrequiredTotal number of words participating in the reveal. Used with index to compute the slice.
transform(localProgress: MotionValue<number>) => MotionStyle({ opacity: p })Per-word transform builder. Same signature as TextReveal.Text.
revealAtnumber0.5Local-progress threshold for the data-state flip.
classNamestringMerged via cn onto the underlying motion.span.
...restOmit<HTMLMotionProps<"span">, "children">Forwarded to the underlying motion.span.

Data attributes: data-slot="text-reveal-word", data-state="hidden" | "revealed", data-index="<n>".

Transform signature

import type { TextRevealTransform } from "@togetheragency/ui/text-reveal"; const fade: TextRevealTransform = (p) => ({ opacity: p }); const slideUp: TextRevealTransform = (p) => ({ opacity: useTransform(p, [0, 0.4], [0, 1]), y: useTransform(p, [0, 1], [20, 0]), });

Because the function runs inside the word’s render, you can call hooks such as useTransform, useSpring, or useMotionTemplate directly — keep the hook order stable across renders, as usual.

Exported types

import type { TextRevealRootProps, TextRevealTextProps, TextRevealTransform, TextRevealWordProps, } from "@togetheragency/ui/text-reveal";
Last updated on