Scroll Reveal Text
Reveals text character-by-character as the user scrolls past it, combining color, fade, blur, and slide effects — scrubbing forward and backward with scroll position instead of firing once.
Unlike MotionReveal, this tracks continuous scroll progress rather than a
one-shot viewport-enter trigger — it scrubs forward and backward as the user
scrolls instead of animating in once and staying put.
It works by walking the rendered DOM and wrapping text nodes in per-character
spans after mount, rather than taking a declarative text prop, so arbitrary
rich children (bold, links, portable-text output) can be passed straight
through.
A few conceptual notes before diving in:
- Reveal methods combine, not select one.
revealis an object keyed by method name (color,fade,blur,slide) — any combination can be present, each contributing its own hidden/revealed style to every character, with no implicit bundling between them.slideonly moves — combine it withfadeexplicitly if you also want characters to fade in as they slide. - Content is captured once at mount. Wrapping text into per-character
spans happens once after mount, not on every render — it isn't designed to
react to
childrenchanging later, since re-wrapping would mutate DOM nodes React itself still thinks it owns. - Thresholds are evenly spaced per character, not tied to layout. Each character's reveal threshold is simply its position divided by the total character count — a steady left-to-right reveal regardless of word length or line breaks.
triggerneeds scroll room to work. LikeMotionReveal'smargin, the reveal only completes if there's enough page content above and below the target to actually scroll it through the configured range — an element that's already fully in view, or pinned near the top or bottom of the page, may never scrub through its full range.- Some effects aren't offered, on purpose.
transform(the natural tool for scaling or rotating a character) doesn't apply to non-replaced inline elements — it only takes effect once a character becomesinline-block, which isolates it into its own atomic shaping unit. That breaks cross-character kerning and ligatures (fi,fl), since a browser can only shape a contiguous run of same-sized inline text together, not independent boxes. The cost is small at body-text sizes but obvious at the large display sizes this component is most often used for, so effects that require it (scale, rotate) are left out rather than shipped with degraded typography.slideavoids the trade-off entirely by usingposition: relative+top/leftinstead oftransform, which stays inline-safe.
import { ScrollRevealText } from '@/animations';
<ScrollRevealText reveal={{ color: { to: 'var(--color-primary-500)' } }}>
Built for <strong>arbitrary rich content</strong> — bold text, links, and
anything else you'd normally pass as children.
</ScrollRevealText>;
// Every option, all four methods combined:
<ScrollRevealText
reveal={{
color: {
from: '#9ca3af',
to: 'var(--color-primary-500)',
},
fade: {
from: 0,
to: 1,
},
blur: {
from: 8,
to: 0,
},
slide: {
axis: 'y',
distance: 50,
},
}}
trigger={{
startAt: 80,
endAt: 60,
}}
className="text-3xl font-medium"
>
Built for <strong>arbitrary rich content</strong> — bold text, links, and
anything else you'd normally pass as children.
</ScrollRevealText>;Props
| Prop | Default | Notes |
|---|---|---|
children | — | Any rich content — captured once at mount and wrapped in per-character spans. |
reveal | { color: { to: 'var(--color-primary-500)' } } | Reveal method(s) applied to each character once scroll progress crosses its threshold. Keys combine. |
trigger | { startAt: 80, endAt: 60 } | Percent down the viewport where the reveal starts/completes. For anything this doesn't cover, pass Motion's own useScroll offset tuple directly, e.g. ['start end', 'end start']. |
transitionDuration | 300 | Ms for each character's snap between its hidden and revealed style. |
className | undefined | Applied to the wrapping div. |
Reveal methods
reveal is an object keyed by method name — any combination of keys can be
present, and each contributes its own hidden/revealed style to every
character.
| Method | Params | Notes |
|---|---|---|
color | from?: string, to: string | Snaps between CSS colors. from defaults to inherited (unset). |
fade | from?: number, to?: number | Opacity, 0–1. Defaults to 0 → 1. |
blur | from?: number, to?: number | Blur radius in px. Defaults to 8 → 0. |
slide | axis?: 'x' | 'y', distance?: number | Slides in from distance% of the character's own size (default 50). Position only — combine with fade for an opacity change too. |