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.
Disclaimer
Browser-based translation, combined with how the reveal animation is implemented, creates a few accessibility challenges that don't come up in most components. Here's what they are and how they're handled:
- Screen readers, crawlers, and translation tools can't make sense of text
split into per-letter spans. The animated copy is
aria-hiddenandtranslate="no"; a visually hidden (sr-only), natural, fully-marked-up duplicate ofchildrenis what they actually read instead — accessibility, SEO, and translation all work correctly regardless of the animation underneath. - Translating the page wouldn't normally reach the animation itself.
Rather than being built from
childrendirectly, the animated copy is cloned and re-split from thesr-onlyduplicate's live DOM — and aMutationObserveron that duplicate re-triggers the clone/re-split whenever browser translation rewrites its text, so the visible animation updates too instead of staying frozen in the original language. - Links aren't supported. Per-letter splitting can't give a link
meaningful hover or focus feedback in either copy, so this isn't a place
links should end up — the expectation is that upstream content (e.g. CMS
rich text) is configured not to allow links in the first place. As a
precaution, any
<a>that does arrive is unwrapped in both copies — no anchor, no wrapper, just its text inlined in place — rather than left as a broken or misleading interactive element. - The one gap: all of this depends on the translator's own behavior. It
relies on the translator finding and rewriting the natural duplicate, and
respecting an opt-out on the animated copy — neither is standardized
across every browser/tool. Prefer plain text or
MotionRevealfor content where correct translation is critical.
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, portable-text output) can be passed straight through —
except links, which aren't supported (see the disclaimer above).
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. - The
childrenprop is captured once at mount. Wrapping text into per-character spans doesn't re-run on every render just becausechildrenchanged — it isn't designed to react to that, since re-wrapping would mutate DOM nodes React itself still thinks it owns. It does, however, re-run in response to the DOM mutations browser translation makes, since those happen outside React entirely — see the disclaimer above for how that (and links) are handled. - 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, italics, and
anything else you'd normally pass as children (except links).
</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, italics, and
anything else you'd normally pass as children (except links).
</ScrollRevealText>;Props
| Prop | Default | Notes |
|---|---|---|
children | — | Any rich content except links (see disclaimer) — 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. |