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. 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.
  • 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 children changing 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.
  • 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, links, and anything else you'd normally pass as children, colored in character-by-character as the page scrolls.
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

PropDefaultNotes
childrenAny 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'].
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.