component

Text Reveal Box

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

Made by Axyl

Dependencies

gsap

Installation

Client component — lazy-loads GSAP ScrollTrigger in useEffect (SSR-safe). Scroll is a tall track + sticky viewport (not GSAP pin): pinDuration sets how many viewport-heights you scroll while words reveal. Respects prefers-reduced-motion.

How to use

Basic

Neutral defaults (bg-background, text-foreground). Style via *ClassName props.

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}
      paragraphClassName="text-center text-3xl font-medium tracking-tight md:text-5xl"
      highlightBg="60, 60, 60"
      pinDuration={4}
    />
  );
}

Studio preset

Bundled dark manifesto typography — pass TEXT_REVEAL_BOX_STUDIO_CLASSES into class slots:

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

<TextRevealBox
  className={studio.root}
  stickyClassName={studio.sticky}
  containerClassName={studio.container}
  paragraphClassName={studio.paragraph}
  wordClassName={studio.word}
  keywordClassName={studio.keyword}
  paragraphs={paragraphs}
  highlightBg="60, 60, 60"
  pinDuration={4}
/>;

Docs preview

embedded = transparent surface + theme-aware highlight. Pass scroller when inside a scroll panel.

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

Keywords & timing

<TextRevealBox
  paragraphs={["Systems design meets psychological tension."]}
  keywords={["systems", "tension"]}
  keywordColors={{ systems: "#c8e600", tension: "#f7f5f0" }}
  keywordClassName={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 wordClassName, 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

Pass any of these to override a layer (cn() merges after built-ins):

  • className — root <section data-slot="text-reveal-box">
  • trackClassName — scroll track (ScrollTrigger trigger)
  • stickyClassName — sticky viewport (h-svh, p-8)
  • innerClassName — flex center wrapper
  • containerClassName — width container (default max-w-3xl)
  • paragraphClassName — each <p>
  • wordClassName.trb-word wrapper
  • keywordWrapperClassName / keywordClassName — 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
scroller?
Element | Window
-
highlightBg?
string
"237, 235, 231"
highlightAlpha?
number
1
timing?
TextRevealBoxTiming
-
embedded?
boolean
false
keywords?
string[]
[]
keywordColors?
Record<string, string>
{}
normalizeWord?
(word: string) => string
-
matchKeyword?
(word: string, keywords: string[]) => boolean
-
className?
string
-
trackClassName?
string
-
stickyClassName?
string
-
innerClassName?
string
-
containerClassName?
string
-
paragraphClassName?
string
-
wordClassName?
string
-
keywordWrapperClassName?
string
-
keywordClassName?
string
-
Loading preview...