Reveals text by character, word or line with eight entrances (fade, blur, slide, scale, rotate), staggered and triggered on scroll, with the full text kept for screen readers.
Design systems that feel alive
Each line arrives on its own. Nothing moves if you prefer it that way.import { TextAnimate } from "@/components/ballmac/text-animate"
export default function TextAnimateDemo() {
return (
<div className="grid max-w-xl gap-3 text-center">
<TextAnimate as="h2" by="word" animation="blurIn" inView={false} className="text-4xl font-semibold tracking-tight text-foreground">
Design systems that feel alive
</TextAnimate>
<TextAnimate by="line" animation="slideUp" inView={false} delay={0.5} className="text-base text-muted-foreground">
{"Each line arrives on its own.\nNothing moves if you prefer it that way."}
</TextAnimate>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/text-animateInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/text-animate.tsx// Ballmac UI: Text Animate. https://ui.ballmac.com/components/text-animate "use client" import * as React from "react" import { motion, useInView, useReducedMotion, type Variants } from "motion/react" import { cn } from "@/lib/utils" type TextAnimation = "fadeIn" | "blurIn" | "slideUp" | "slideDown" | "slideLeft" | "slideRight" | "scaleUp" | "rotateIn" const PRESETS: Record<TextAnimation, { hidden: Record<string, number | string>; visible: Record<string, number | string> }> = { fadeIn: { hidden: { opacity: 0 }, visible: { opacity: 1 } }, blurIn: { hidden: { opacity: 0, filter: "blur(10px)" }, visible: { opacity: 1, filter: "blur(0px)" } }, slideUp: { hidden: { opacity: 0, y: "0.6em" }, visible: { opacity: 1, y: 0 } }, slideDown: { hidden: { opacity: 0, y: "-0.6em" }, visible: { opacity: 1, y: 0 } }, slideLeft: { hidden: { opacity: 0, x: "0.6em" }, visible: { opacity: 1, x: 0 } }, slideRight: { hidden: { opacity: 0, x: "-0.6em" }, visible: { opacity: 1, x: 0 } }, scaleUp: { hidden: { opacity: 0, scale: 0.4 }, visible: { opacity: 1, scale: 1 } }, rotateIn: { hidden: { opacity: 0, rotateX: -90, y: "0.4em" }, visible: { opacity: 1, rotateX: 0, y: 0 } }, } type TextAnimateTag = "span" | "p" | "div" | "h1" | "h2" | "h3" | "h4" type TextAnimateProps = Omit<React.ComponentProps<"span">, "children"> & { /** The text. Use a newline to break lines. */ children: string /** What is animated one after another. */ by?: "character" | "word" | "line" /** How each piece arrives. */ animation?: TextAnimation /** Seconds between pieces. Defaults to a pace that suits `by`. */ stagger?: number /** Seconds each piece takes. */ duration?: number /** Seconds before the first piece. */ delay?: number /** Wait for the text to scroll into view. */ inView?: boolean /** Play once. */ once?: boolean /** Element to render. */ as?: TextAnimateTag } function TextAnimate({ children: text, by = "word", animation = "blurIn", stagger, duration = 0.4, delay = 0, inView = true, once = true, as = "span", className, ...props }: TextAnimateProps) { const ref = React.useRef<HTMLElement>(null) const reduce = useReducedMotion() const seen = useInView(ref, { once, margin: "0px 0px -10% 0px" }) const show = reduce || (inView ? seen : true) const step = stagger ?? (by === "character" ? 0.03 : by === "word" ? 0.07 : 0.15) const preset = PRESETS[animation] const Tag = as as React.ElementType const variants: Variants = { hidden: preset.hidden, visible: (i: number) => ({ ...preset.visible, transition: { delay: delay + i * step, duration, ease: [0.22, 1, 0.36, 1] }, }), } const lines = text.split("\n") let index = 0 const piece = (content: string, key: React.Key) => ( <motion.span key={key} custom={index++} variants={variants} initial={false} animate={show ? "visible" : "hidden"} style={{ display: "inline-block", whiteSpace: "pre", transformOrigin: "50% 100%" }} aria-hidden="true" > {content} </motion.span> ) return ( <Tag ref={ref} data-slot="text-animate" className={cn("inline-block", className)} style={{ perspective: 600 }} {...props}> <span className="sr-only">{text}</span> <span aria-hidden="true"> {lines.map((line, li) => ( <React.Fragment key={li}> {li > 0 && <br />} {by === "line" ? piece(line, li) : line.split(/(\s+)/).map((word, wi) => { if (/^\s+$/.test(word)) return <React.Fragment key={wi}> </React.Fragment> if (by === "word") return piece(word, wi) return ( <span key={wi} style={{ display: "inline-block", whiteSpace: "nowrap" }}> {Array.from(word).map((ch, ci) => piece(ch, ci))} </span> ) })} </React.Fragment> ))} </span> </Tag> ) } export { TextAnimate, type TextAnimateProps, type TextAnimation }Update the import paths to match your project setup.
Usage
import { TextAnimate } from "@/components/ballmac/text-animate"The full example is in the Code tab above.
Examples
By character, five entrances
- Hello, worldfadeIn
- Hello, worldslideUp
- Hello, worldscaleUp
- Hello, worldrotateIn
- Hello, worldslideLeft
import { TextAnimate, type TextAnimation } from "@/components/ballmac/text-animate"
const kinds: TextAnimation[] = ["fadeIn", "slideUp", "scaleUp", "rotateIn", "slideLeft"]
export default function TextAnimateCharacters() {
return (
<ul className="grid w-full max-w-sm gap-2">
{kinds.map((animation, i) => (
<li key={animation} className="flex items-baseline justify-between gap-4 rounded-lg border bg-card px-4 py-2.5">
<TextAnimate by="character" animation={animation} inView={false} delay={i * 0.15} className="text-lg font-medium">
Hello, world
</TextAnimate>
<span className="font-mono text-xs text-muted-foreground">{animation}</span>
</li>
))}
</ul>
)
}API reference
| Prop | Type | Default |
|---|---|---|
children*The text. Use a newline to break lines. | string | — |
byWhat is animated one after another. | "character" | "word" | "line" | "word" |
animationHow each piece arrives. | TextAnimation | "blurIn" |
staggerSeconds between pieces. Defaults to a pace that suits `by`. | number | — |
durationSeconds each piece takes. | number | 0.4 |
delaySeconds before the first piece. | number | 0 |
inViewWait for the text to scroll into view. | boolean | true |
oncePlay once. | boolean | true |
asElement to render. | TextAnimateTag | "span" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | The full text is in a hidden element; animated pieces are aria-hidden |
| Reduced motion | Text is shown immediately |
Use with AI
<TextAnimate by='word|character|line' animation='blurIn|fadeIn|slideUp|slideDown|slideLeft|slideRight|scaleUp|rotateIn'>Text</TextAnimate>. Newlines make lines. as picks the element. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Text Animate (@ballmac/text-animate) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Headlines and short statements that should make an entrance
- Section titles revealed on scroll
Not for
- Paragraphs of body text
- Text that changes often (use word-rotate or typing-text)
Registry JSON: https://ui.ballmac.com/r/text-animate.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Blur Fade
A wrapper that fades content in while it unblurs and slides a few pixels, once it scrolls into view, with a group that staggers its children.
Gradient Text
Text filled with a gradient of theme colors that slowly drifts, or a shiny variant that sweeps a glint of light across it on a pause. Static under reduced motion; plain text in forced colors.
Text Reveal
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.
Highlighter
Hand-drawn marks around inline text: highlight, underline, box, circle, strike-through and brackets, drawn in when the phrase scrolls into view.