Cycles one word of a sentence with a vertical slide and blur while its width springs to the next word, so the rest of the line reflows smoothly. Screen readers hear one stable sentence.
Ship faster, safer, together
without the pager.
Previews, rollbacks and logs in one calm place, so releases stop being events.
import { ArrowRight } from "lucide-react"
import { buttonVariants } from "@/components/ballmac/button"
import { WordRotate } from "@/components/ballmac/word-rotate"
export default function WordRotateDemo() {
return (
<div className="flex w-full max-w-xl flex-col items-center text-center">
<span className="rounded-full border bg-background px-3 py-1 font-mono text-xs text-muted-foreground">
v3.0 · now with branch previews
</span>
<h2 className="mt-5 text-4xl font-semibold tracking-[-0.035em] sm:text-6xl">
Ship{" "}
<WordRotate
words={["faster", "safer", "together"]}
wordClassName="bg-[linear-gradient(100deg,var(--chart-1),var(--chart-4)_55%,var(--chart-5))] bg-clip-text pe-[0.04em] text-transparent"
/>
<br />
without the pager.
</h2>
<p className="mt-4 max-w-md text-balance text-muted-foreground">
Previews, rollbacks and logs in one calm place, so releases stop being events.
</p>
<div className="mt-6 flex gap-3">
<a href="#" className={buttonVariants({ shape: "pill" })}>
Start free <ArrowRight aria-hidden="true" className="rtl:rotate-180"/>
</a>
<a href="#" className={buttonVariants({ variant: "outline", shape: "pill" })}>
Read the docs
</a>
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/word-rotateInstall 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/word-rotate.tsx// Ballmac UI: Word Rotate. https://ui.ballmac.com/components/word-rotate "use client" import * as React from "react" import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { duration, ease } from "@/lib/ballmac/motion" type WordRotateProps = Omit<React.ComponentProps<"span">, "children"> & { /** Words to cycle through, in order. */ words: string[] /** Milliseconds each word stays before the next slides in. */ interval?: number /** Stop on the current word. */ paused?: boolean /** Class names for each word (color, gradient, weight). */ wordClassName?: string /** * What screen readers hear instead of the changing word. Defaults to the words joined with * commas, so the sentence reads once, completely, without live announcements. */ srText?: string } const WIDTH_SPRING = { type: "spring", stiffness: 260, damping: 30 } as const function WordRotate({ words, interval = 2400, paused = false, wordClassName, srText, className, ...props }: WordRotateProps) { const rootRef = React.useRef<HTMLSpanElement>(null) const measureRef = React.useRef<HTMLSpanElement>(null) const reduceMotion = useReducedMotion() const inView = useInView(rootRef) const [index, setIndex] = React.useState(0) const [widths, setWidths] = React.useState<number[]>([]) const count = words.length const current = index % Math.max(count, 1) // Measure every word once (and again when fonts load or the size changes). React.useEffect(() => { const box = measureRef.current if (!box) return const measure = () => { const next = Array.from(box.children, (child) => (child as HTMLElement).getBoundingClientRect().width) setWidths((prev) => (prev.length === next.length && prev.every((w, i) => w === next[i]) ? prev : next)) } measure() const observer = new ResizeObserver(measure) observer.observe(box) return () => observer.disconnect() }, [words]) React.useEffect(() => { if (paused || !inView || count < 2) return const timer = window.setInterval(() => setIndex((i) => (i + 1) % count), interval) return () => window.clearInterval(timer) }, [paused, inView, count, interval]) const width = widths[current] const word = words[current] ?? "" const hidden = reduceMotion ? { opacity: 0 } : { opacity: 0, y: "0.6em", filter: "blur(6px)" } const leaving = reduceMotion ? { opacity: 0 } : { opacity: 0, y: "-0.6em", filter: "blur(6px)" } return ( <span ref={rootRef} data-slot="word-rotate" className={cn("relative inline-block", className)} {...props}> <span className="sr-only">{srText ?? words.join(", ")}</span> <motion.span aria-hidden="true" data-slot="word-rotate-viewport" className="relative inline-block whitespace-nowrap [clip-path:inset(-0.3em_-0.12em)]" initial={false} animate={width ? { width } : undefined} transition={reduceMotion ? { duration: 0 } : WIDTH_SPRING} > {/* In-flow copy: gives the line its height and baseline. */} <span className={cn("invisible", wordClassName)}>{word}</span> <AnimatePresence initial={false}> <motion.span key={`${current}-${word}`} data-slot="word-rotate-word" className={cn("absolute top-0 start-0", wordClassName)} initial={hidden} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={leaving} transition={{ duration: duration.reveal, ease: ease.out }} > {word} </motion.span> </AnimatePresence> </motion.span> {/* Off-screen measurer with the same styles as the words. */} <span ref={measureRef} aria-hidden="true" className="pointer-events-none invisible absolute top-0 start-0 flex h-0 w-0 overflow-hidden whitespace-nowrap" > {words.map((w, i) => ( <span key={i} className={cn("shrink-0", wordClassName)}> {w} </span> ))} </span> </span> ) } export { WordRotate, type WordRotateProps }Update the import paths to match your project setup.
Usage
import { WordRotate } from "@/components/ballmac/word-rotate"The full example is in the Code tab above.
Examples
Inline highlight
Built for designers, engineers, founders, support teams who sweat the details.
import { WordRotate } from "@/components/ballmac/word-rotate"
export default function WordRotateInline() {
return (
<p className="max-w-md text-center text-xl font-medium tracking-tight text-balance">
Built for{" "}
<WordRotate
words={["designers", "engineers", "founders", "support teams"]}
interval={2000}
wordClassName="rounded-md bg-accent px-1.5 text-accent-foreground"
/>{" "}
who sweat the details.
</p>
)
}API reference
| Prop | Type | Default |
|---|---|---|
words*Words to cycle through, in order. | string[] | — |
intervalMilliseconds each word stays before the next slides in. | number | 2400 |
pausedStop on the current word. | boolean | false |
wordClassNameClass names for each word (color, gradient, weight). | string | — |
srTextWhat screen readers hear instead of the changing word. Defaults to the words joined with commas, so the sentence reads once, completely, without live announcements. | string | — |
Also accepts the standard attributes of its root element.
Use with AI
Place <WordRotate words={["faster", "safer"]} /> inside a heading or paragraph; it inherits the font. Style the words with wordClassName (gradients, highlights). The accessible text is srText or the words joined with commas; the animated part is aria-hidden and not a live region. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Word Rotate (@ballmac/word-rotate) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Hero headlines that name several audiences or benefits
- Short inline highlights in a tagline
Not for
- Information people must read in full (rotating text is easy to miss)
- Long phrases or more than about five options
Registry JSON: https://ui.ballmac.com/r/word-rotate.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
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.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Highlighter
Hand-drawn marks around inline text: highlight, underline, box, circle, strike-through and brackets, drawn in when the phrase scrolls into view.
Hyper Text
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.