Characters flip through random letters like a split-flap board and settle left to right, as plain text or on tiles, on load, on scroll or on hover and focus.
import { HyperText } from "@/components/ballmac/hyper-text"
export default function HyperTextDemo() {
return (
<div className="grid gap-4 text-center">
<HyperText variant="tiles" className="justify-center text-3xl sm:text-4xl">
Departures
</HyperText>
<HyperText variant="tiles" speed={45} className="justify-center text-xl text-foreground">
Gate B14
</HyperText>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/hyper-textInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/hyper-text.tsx// Ballmac UI: Hyper Text. https://ui.ballmac.com/components/hyper-text "use client" import * as React from "react" import { motion, useInView, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" type HyperTextProps = Omit<React.ComponentProps<"span">, "children"> & { /** The final text. */ children: string /** When to play. "hover" also plays on keyboard focus. */ trigger?: "mount" | "inView" | "hover" /** "tiles" draws each character on a split-flap tile. */ variant?: "plain" | "tiles" /** Milliseconds between flips of one character. */ speed?: number /** Extra flips each character makes, per position, so the word settles left to right. */ spread?: number /** Called when every character has settled. */ onComplete?: () => void } function Flap({ target, run, steps, speed, tile }: { target: string; run: number; steps: number; speed: number; tile: boolean }) { const [char, setChar] = React.useState(target) const reduce = useReducedMotion() React.useEffect(() => { if (!run || reduce || target === " ") { setChar(target) return } let n = 0 const id = setInterval(() => { n++ if (n >= steps) { setChar(target) clearInterval(id) } else { setChar(ALPHABET[Math.floor(Math.random() * ALPHABET.length)]!) } }, speed) return () => clearInterval(id) }, [run, target, steps, speed, reduce]) if (target === " ") return <span className={tile ? "w-[0.5em]" : "whitespace-pre"}>{tile ? "" : " "}</span> return ( <span className={cn( "relative inline-flex h-[1.35em] min-w-[0.75em] items-center justify-center overflow-hidden", tile && "rounded-[0.18em] bg-foreground px-[0.12em] text-background shadow-[inset_0_-0.08em_0_0_rgb(0_0_0/0.25)] after:absolute after:inset-x-0 after:top-1/2 after:h-px after:bg-background/30 after:content-['']" )} > <motion.span key={char} className="inline-block" initial={reduce ? false : { y: "-70%", opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: Math.min(speed / 1000, 0.09), ease: "easeOut" }} > {char} </motion.span> </span> ) } function HyperText({ children: text, trigger = "mount", variant = "plain", speed = 55, spread = 2, onComplete, className, onPointerEnter, onFocus, ...props }: HyperTextProps) { const ref = React.useRef<HTMLSpanElement>(null) const seen = useInView(ref, { once: true }) const [run, setRun] = React.useState(0) const chars = Array.from(text) const completeRef = React.useRef(onComplete) React.useEffect(() => { completeRef.current = onComplete }) React.useEffect(() => { if (trigger === "mount" || (trigger === "inView" && seen)) setRun((r) => r + 1) }, [trigger, seen]) React.useEffect(() => { if (!run) return const total = (4 + chars.length * spread) * speed + 60 const id = setTimeout(() => completeRef.current?.(), total) return () => clearTimeout(id) }, [run, chars.length, spread, speed]) const hover = trigger === "hover" return ( <span ref={ref} data-slot="hyper-text" tabIndex={hover ? 0 : undefined} onPointerEnter={(e) => { onPointerEnter?.(e) if (hover) setRun((r) => r + 1) }} onFocus={(e) => { onFocus?.(e) if (hover) setRun((r) => r + 1) }} className={cn( "inline-flex flex-wrap items-center font-mono font-semibold tracking-tight", variant === "tiles" ? "gap-[0.12em]" : "gap-0", hover && "rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className )} {...props} > <span className="sr-only">{text}</span> <span aria-hidden="true" className="inline-flex flex-wrap items-center gap-[inherit]"> {chars.map((c, i) => ( <Flap key={i} target={c.toUpperCase()} run={run} steps={4 + i * spread} speed={speed} tile={variant === "tiles"} /> ))} </span> </span> ) } export { HyperText, type HyperTextProps }Update the import paths to match your project setup.
Usage
import { HyperText } from "@/components/ballmac/hyper-text"The full example is in the Code tab above.
Examples
Replay on hover
Also replays when focused with the keyboard.
import { HyperText } from "@/components/ballmac/hyper-text"
export default function HyperTextHover() {
return (
<div className="grid justify-items-center gap-2">
<HyperText trigger="hover" className="text-4xl text-foreground">
Hover me
</HyperText>
<p className="text-xs text-muted-foreground">Also replays when focused with the keyboard.</p>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
children*The final text. | string | — |
triggerWhen to play. "hover" also plays on keyboard focus. | "mount" | "inView" | "hover" | "mount" |
variant"tiles" draws each character on a split-flap tile. | "plain" | "tiles" | "plain" |
speedMilliseconds between flips of one character. | number | 55 |
spreadExtra flips each character makes, per position, so the word settles left to right. | number | 2 |
onCompleteCalled when every character has settled. | () => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Reaches the text when trigger is hover; focus replays the animation |
| Screen readers | The real text is read once; the flipping characters are hidden |
| Reduced motion | Characters appear settled |
Use with AI
<HyperText trigger='mount|inView|hover' variant='plain|tiles' speed spread>TEXT</HyperText>. Text is uppercased. The hover trigger is focusable and replays on focus. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Hyper Text (@ballmac/hyper-text) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Brand words, status boards, dates and counters
- Playful hover headings
Not for
- Anything that must stay readable mid-animation
- Long sentences
Registry JSON: https://ui.ballmac.com/r/hyper-text.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Scramble Text
Decodes text in place: each character cycles through random glyphs before settling, left to right. Plays on mount, in view or on hover, keeps its final width, and exposes the final text to screen readers.
Number Ticker
Counts up to a number when it scrolls into view, with locale-aware formatting for currency, percentages and decimals.
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.
Highlighter
Hand-drawn marks around inline text: highlight, underline, box, circle, strike-through and brackets, drawn in when the phrase scrolls into view.