Text laid out on a circle that turns slowly, with a center slot for a logo or arrow, pausing on hover.
import { ArrowDown } from "lucide-react"
import { SpinningText } from "@/components/ballmac/spinning-text"
export default function SpinningTextDemo() {
return (
<SpinningText text="Scroll to explore the library" className="size-44">
<span className="flex size-12 items-center justify-center rounded-full bg-foreground text-background">
<ArrowDown aria-hidden="true" className="size-5" />
</span>
</SpinningText>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/spinning-textInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/spinning-text.tsx// Ballmac UI: Spinning Text. https://ui.ballmac.com/components/spinning-text "use client" import * as React from "react" import { motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" type SpinningTextProps = Omit<React.ComponentProps<"div">, "children"> & { /** The words that run around the circle. */ text: string /** Shown between repeats of the text. */ separator?: string /** Seconds for one full turn. */ duration?: number /** Turn counter-clockwise. */ reverse?: boolean /** Pause the spin while the pointer is over it or it has keyboard focus. */ pauseOnHover?: boolean /** Content in the middle of the ring, such as a logo or an arrow. */ children?: React.ReactNode } function SpinningText({ text, separator = " • ", duration = 14, reverse = false, pauseOnHover = true, className, children, ...props }: SpinningTextProps) { const reduce = useReducedMotion() const id = React.useId().replace(/:/g, "") const [paused, setPaused] = React.useState(false) const label = text.trim() const run = `${label}${separator}` // Fit the string to the circle: every character gets a small even share of the circumference. const radius = 76 const circumference = 2 * Math.PI * radius return ( <div data-slot="spinning-text" role="img" aria-label={label} onPointerEnter={() => pauseOnHover && setPaused(true)} onPointerLeave={() => setPaused(false)} className={cn("relative inline-flex size-40 items-center justify-center text-foreground", className)} {...props} > <motion.svg aria-hidden="true" viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible" animate={reduce || paused ? undefined : { rotate: reverse ? -360 : 360 }} transition={{ duration, ease: "linear", repeat: Infinity }} style={{ originX: "50%", originY: "50%" }} > <defs> <path id={id} d={`M100 100 m-${radius} 0 a${radius} ${radius} 0 1 1 ${radius * 2} 0 a${radius} ${radius} 0 1 1 -${radius * 2} 0`} /> </defs> <text fill="currentColor" className="font-medium uppercase" style={{ fontSize: 15, letterSpacing: "0.04em" }}> <textPath href={`#${id}`} textLength={circumference - 4} lengthAdjust="spacing"> {run} </textPath> </text> </motion.svg> {children && <div className="relative flex items-center justify-center">{children}</div>} </div> ) } export { SpinningText, type SpinningTextProps }Update the import paths to match your project setup.
Usage
import { SpinningText } from "@/components/ballmac/spinning-text"The full example is in the Code tab above.
Examples
Sizes and direction
import { SpinningText } from "@/components/ballmac/spinning-text"
export default function SpinningTextSizes() {
return (
<div className="flex flex-wrap items-center justify-center gap-6">
<SpinningText text="Now in beta" className="size-28" duration={10} />
<SpinningText text="Made with care · Since 2024" className="size-40" reverse>
<span className="text-2xl font-semibold">B</span>
</SpinningText>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
text*The words that run around the circle. | string | — |
separatorShown between repeats of the text. | string | " • " |
durationSeconds for one full turn. | number | 14 |
reverseTurn counter-clockwise. | boolean | false |
pauseOnHoverPause the spin while the pointer is over it or it has keyboard focus. | boolean | true |
childrenContent in the middle of the ring, such as a logo or an arrow. | React.ReactNode | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | The ring is an image named by the text; the center slot is read normally |
| Reduced motion | The ring stays still |
Use with AI
<SpinningText text duration reverse pauseOnHover className='size-40'>{center}</SpinningText>. The string is fitted to the circle, so shorter text spreads wider. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Spinning Text (@ballmac/spinning-text) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Scroll-down or 'new' badges
- Decorative seals and stamps
Not for
- Information people need to read (rotating text is hard to read)
Registry JSON: https://ui.ballmac.com/r/spinning-text.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Orbiting Circles
Items that orbit a center on circular tracks while staying upright. Evenly spaced, any radius, speed and direction; stack several for rings. Paused off-screen, static under reduced motion.
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.
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.