component

Text Reveal Box

Scroll-pinned word-by-word text reveal with a highlight flash, powered by GSAP ScrollTrigger.

Made by Axyl

Installation

File Structure

text-reveal-box.tsx
use-prefers-reduced-motion.tsx
scroll-trigger-utils.tsx

Usage

Basic

Neutral defaults (bg-background, text-foreground). Style via the classNames prop.

import { TextRevealBox } from "@/components/sora-ui/texts/text-reveal-box";

const paragraphs = [
  "Your first paragraph of manifesto copy.",
  "A second paragraph that continues the scroll-driven reveal.",
];

export default function Section() {
  return (
    <TextRevealBox
      paragraphs={paragraphs}
      classNames={{
        paragraph: "text-center text-3xl font-medium tracking-tight md:text-5xl",
      }}
      highlightBg="60, 60, 60"
      pinDuration={4}
    />
  );
}

Dark serif preset (variant="studio")

Near-black canvas, uppercase serif copy, and keyword pills tuned for long-form manifesto sections:

<TextRevealBox
  paragraphs={paragraphs}
  highlightBg="60, 60, 60"
  pinDuration={4}
  variant="studio"
/>

variant="minimal" (default) uses theme tokens (bg-background, text-foreground). Override any slot via the classNames prop.

Custom class map (advanced)

Import TEXT_REVEAL_BOX_STUDIO_CLASSES to extend the studio preset:

import {
  TEXT_REVEAL_BOX_STUDIO_CLASSES as studio,
  TextRevealBox,
} from "@/components/sora-ui/texts/text-reveal-box";

<TextRevealBox
  variant="studio"
  classNames={{ paragraph: cn(studio.paragraph, "text-balance") }}
  paragraphs={paragraphs}
  pinDuration={4}
/>;

Docs preview

embedded = transparent surface + theme-aware highlight (minimal) or studio canvas (variant="studio"). Pass scroller when inside a scroll panel.

<TextRevealBox
  embedded
  scroller={viewport}
  paragraphs={paragraphs}
  pinDuration={4}
  variant="studio"
/>

Scroll uses a tall track + sticky viewport (not GSAP pin): pinDuration sets how many viewport-heights you scroll while words reveal.

Keywords & timing

<TextRevealBox
  paragraphs={["Systems design meets psychological tension."]}
  keywords={["systems", "tension"]}
  keywordColors={{ systems: "#c8e600", tension: "#f7f5f0" }}
  classNames={{ keyword: studio.keyword }}
  highlightBg="60, 60, 60"
  pinDuration={4}
/>

Default matcher: lowercaseNormalizeWord (strip edge punctuation, lowercase). Override with normalizeWord or matchKeyword.

Lock words after reveal — skip the reverse fade:

<TextRevealBox
  paragraphs={paragraphs}
  timing={{ revealPortion: 0.7, revealOverlap: 15, reverseOnScroll: false }}
/>

Styling

Word spacing

Words are adjacent <span> nodes without spaces in markup. Gaps come from built-in mr-[0.2rem] mb-[0.2rem] on .trb-word. If you override classNames.word, keep equivalent margins or pills will touch.

GSAP inline styles

ScrollTrigger sets opacity and backgroundColor on each .trb-word, and opacity on the inner span. CSS alone cannot drive the reveal — only initial hidden state and pill shape.

Class slots

className styles the root <section data-slot="text-reveal-box">. Pass any of these to classNames to override an inner layer (cn() merges after built-ins):

  • track — scroll track (ScrollTrigger trigger)
  • sticky — sticky viewport (h-svh, p-8)
  • inner — flex center wrapper
  • container — width container (default max-w-3xl)
  • paragraph — each <p>
  • word.trb-word wrapper
  • keywordWrapper / keyword — keyword pill (before: + --kw-color)

CSS variables

On the root section:

  • --trb-pin-duration — from pinDuration; drives h-[calc(var(--trb-pin-duration)*100svh)]
  • --trb-highlight-bg — RGB triplet, no rgb() wrapper (e.g. 60,60,60)
  • --trb-highlight-alpha — flash opacity 0–1

On keyword inner spans: --kw-color from keywordColors.

Highlight resolves at init: CSS vars on root (e.g. embedded) → else highlightBg + highlightAlpha.

<TextRevealBox
  className="[--trb-highlight-bg:60,60,60] [--trb-highlight-alpha:0.85]"
  paragraphs={paragraphs}
/>

Built-in structure (reference)

Trackh-[calc(var(--trb-pin-duration)*100svh)], max-lg:…100dvh, @/preview:…100cqh for catalog preview.

Word wrapperinline-block rounded-lg p-[0.1rem_0.2rem] opacity-0 will-change-[background-color,opacity] plus spacing margins above.

Scroll phases — first revealPortion (default 70%) reveals with overlap; remainder reverses highlight when reverseOnScroll is true.

Props

PropTypeDefault
paragraphs?
string[]
[]
pinDuration?
number
4
refreshPriority?
number
-1
scroller?
Element | Window
-
highlightBg?
string
"237, 235, 231"
highlightAlpha?
number
1
timing?
TextRevealBoxTiming
-
embedded?
boolean
false
variant?
"minimal" | "studio"
minimal
keywords?
string[]
[]
keywordColors?
Record<string, string>
{}
normalizeWord?
(word: string) => string
-
matchKeyword?
(word: string, keywords: string[]) => boolean
-
className?
string
-
classNames?
TextRevealBoxClassNames
-

Accessibility

Text is in the DOM throughout the reveal. When prefers-reduced-motion: reduce is set, GSAP skips the animation and words are shown immediately.

Built by Axyl. A motion-first component registry for React.

Scroll to reveal text word by word