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-hidden and translate="no"; a visually hidden (sr-only), natural, fully-marked-up duplicate of children is 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 children directly, the animated copy is cloned and re-split from the sr-only duplicate's live DOM — and a MutationObserver on 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 MotionReveal for 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. reveal is 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. slide only moves — combine it with fade explicitly if you also want characters to fade in as they slide.
  • The children prop is captured once at mount. Wrapping text into per-character spans doesn't re-run on every render just because children changed — 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.
  • trigger needs scroll room to work. Like MotionReveal's margin, 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 becomes inline-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. slide avoids the trade-off entirely by using position: relative + top/left instead of transform, which stays inline-safe.

Adds space above and below so there's room to scroll the reveal into and out of view
Built for arbitrary rich content — bold text, italic text, and anything else you'd normally pass as children, colored in character-by-character as the page scrolls. Links aren't supported — see the disclaimer above.
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

PropDefaultNotes
childrenAny 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'].
transitionDuration300Ms for each character's snap between its hidden and revealed style.
classNameundefinedApplied 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.

MethodParamsNotes
colorfrom?: string, to: stringSnaps between CSS colors. from defaults to inherited (unset).
fadefrom?: number, to?: numberOpacity, 0–1. Defaults to 01.
blurfrom?: number, to?: numberBlur radius in px. Defaults to 80.
slideaxis?: 'x' | 'y', distance?: numberSlides in from distance% of the character's own size (default 50). Position only — combine with fade for an opacity change too.