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.
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.Rootwires upmotion’suseScrollagainst itself (scroll mode) or normalizes theprogressprop to aMotionValue(controlled mode). Either way, it exposes the resulting progress to descendants via context.TextReveal.Textaccepts a plain string, splits it on whitespace, and renders each word throughTextReveal.Word. Use thetransformprop to customise the reveal.TextReveal.Wordis 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.
useScrollruns internally, optionally scoped to acontainerref and shaped byoffset. - Provided (
0–100) → 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).
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.
<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.
<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.
<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.
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.
Data attributes: data-slot="text-reveal-text".
TextReveal.Word
Lower-level primitive — one animated word. Renders a 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";