Reveals a headline or paragraph word by word or character by character with a soft blur and rise, when it scrolls into view or on mount.
Interfaces that feel measured, not decorated.
Motion with a purpose, tuned like an instrument.
import { TextReveal } from "@/components/ballmac/text-reveal"
export default function TextRevealDemo() {
return (
<div className="w-full max-w-xl px-4 text-center">
<TextReveal as="h2" className="text-3xl font-semibold tracking-tight text-balance sm:text-5xl">
Interfaces that feel measured, not decorated.
</TextReveal>
<TextReveal as="p" delay={0.5} className="mt-4 text-sm text-muted-foreground sm:text-base">
Motion with a purpose, tuned like an instrument.
</TextReveal>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/text-revealInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presetsCopy the source into your project.
components/ballmac/text-reveal.tsx// Ballmac UI: Text Reveal. https://ui.ballmac.com/components/text-reveal "use client" import * as React from "react" import { motion, useInView, type Variants } from "motion/react" import { cn } from "@/lib/utils" import { stagger, variants } from "@/lib/ballmac/motion" type TextRevealElement = "h1" | "h2" | "h3" | "h4" | "p" | "span" | "div" type TextRevealProps = Omit<React.ComponentProps<"span">, "children"> & { /** The text to reveal. Plain text only, so it can be split and read out once. */ children: string /** Split into words (default) or single characters. */ by?: "word" | "char" /** The element to render. Defaults to "p". */ as?: TextRevealElement /** "inView" (default) waits until the text scrolls into view; "mount" starts right away. */ trigger?: "inView" | "mount" /** Play only the first time the text enters the viewport. Defaults to true. */ once?: boolean /** Seconds before the first piece appears. */ delay?: number /** Seconds between pieces. Defaults to 0.06 for words and 0.018 for characters. */ step?: number } const REDUCED_QUERY = "(prefers-reduced-motion: reduce)" function subscribeReducedMotion(onChange: () => void) { const media = window.matchMedia(REDUCED_QUERY) media.addEventListener("change", onChange) return () => media.removeEventListener("change", onChange) } /** Reduced-motion preference that is false on the server and during hydration, so markup always matches. */ function useReducedMotionSafe() { return React.useSyncExternalStore( subscribeReducedMotion, () => window.matchMedia(REDUCED_QUERY).matches, () => false ) } function TextReveal({ children, by = "word", as = "p", trigger = "inView", once = true, delay = 0, step, className, ...props }: TextRevealProps) { const Comp = as as React.ElementType const ref = React.useRef<HTMLElement>(null) const inView = useInView(ref, { once, amount: 0.4 }) const reduceMotion = useReducedMotionSafe() const text = children const container = React.useMemo<Variants>( () => ({ hidden: {}, visible: { transition: stagger(step ?? (by === "char" ? 0.018 : 0.06), delay) }, }), [by, step, delay] ) if (reduceMotion) { return ( <Comp ref={ref} data-slot="text-reveal" className={className} {...props}> {text} </Comp> ) } const show = trigger === "mount" || inView // Split on whitespace but keep it, so the browser still wraps lines normally. const tokens = text.split(/(\s+)/).filter(Boolean) return ( <Comp ref={ref} data-slot="text-reveal" className={className} {...props}> <span className="sr-only">{text}</span> <motion.span aria-hidden="true" data-slot="text-reveal-content" initial="hidden" animate={show ? "visible" : "hidden"} variants={container} > {tokens.map((token, i) => { if (/^\s+$/.test(token)) return " " if (by === "word") { return ( <motion.span key={i} className="inline-block" variants={variants.fadeUp}> {token} </motion.span> ) } return ( // Keep each word's characters together so lines never break mid-word. <span key={i} className="inline-block whitespace-nowrap"> {Array.from(token).map((char, j) => ( <motion.span key={j} className="inline-block" variants={variants.fadeUp}> {char} </motion.span> ))} </span> ) })} </motion.span> </Comp> ) } export { TextReveal, type TextRevealProps }Update the import paths to match your project setup.
Usage
import { TextReveal } from "@/components/ballmac/text-reveal"The full example is in the Code tab above.
Examples
By character
Now in beta
import { TextReveal } from "@/components/ballmac/text-reveal"
export default function TextRevealChars() {
return (
<div className="flex flex-col items-center gap-2 px-4 text-center">
<span className="font-mono text-xs tracking-widest text-muted-foreground uppercase">Release 4.2</span>
<TextReveal as="h2" by="char" trigger="mount" className="text-4xl font-semibold tracking-tight sm:text-6xl">
Now in beta
</TextReveal>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
children*The text to reveal. Plain text only, so it can be split and read out once. | string | — |
bySplit into words (default) or single characters. | "word" | "char" | "word" |
asThe element to render. Defaults to "p". | TextRevealElement | "p" |
trigger"inView" (default) waits until the text scrolls into view; "mount" starts right away. | "inView" | "mount" | "inView" |
oncePlay only the first time the text enters the viewport. Defaults to true. | boolean | true |
delaySeconds before the first piece appears. | number | 0 |
stepSeconds between pieces. Defaults to 0.06 for words and 0.018 for characters. | number | — |
Also accepts the standard attributes of its root element.
Use with AI
Wrap a plain string: <TextReveal as="h1">Ship the interface</TextReveal>. Screen readers get the full text once from a visually hidden copy; the animated pieces are aria-hidden. Under reduced motion it renders plain text. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Text Reveal (@ballmac/text-reveal) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Hero headlines and section titles on marketing pages
- A short tagline that should land after the page loads (trigger="mount")
- Pull quotes or statements revealed as the reader scrolls
Not for
- Body copy, UI labels or anything users need to read immediately
- Rich text with links or inline elements (children must be a plain string)
- Streaming AI output (render tokens as they arrive instead)
Registry JSON: https://ui.ballmac.com/r/text-reveal.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
Shimmer Text
Text with a band of light sweeping across it, from muted to full foreground color. Suited to AI "thinking" and loading status lines; static under reduced motion.
Animated Grid
A decorative hairline grid background where a few random cells softly light up and fade, like instrument lights. SVG, hydration-safe, static under reduced motion.
Number Ticker
Counts up to a number when it scrolls into view, with locale-aware formatting for currency, percentages and decimals.
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.