Words melt into each other with a blur and threshold effect, cycling through a list, pausing when off screen, with a box that never jumps in width.
import { MorphingText } from "@/components/ballmac/morphing-text"
export default function MorphingTextDemo() {
return <MorphingText className="h-24 text-6xl tracking-tight text-foreground sm:text-7xl" texts={["Design", "Build", "Ship", "Repeat"]} />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/morphing-textInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/morphing-text.tsx// Ballmac UI: Morphing Text. https://ui.ballmac.com/components/morphing-text "use client" import * as React from "react" import { useInView, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" type MorphingTextProps = Omit<React.ComponentProps<"span">, "children"> & { /** The words or phrases to morph between, in order. */ texts: string[] /** Milliseconds each text is held before the next morph starts. */ hold?: number /** Milliseconds the morph itself takes. */ morph?: number } function MorphingText({ texts, hold = 1400, morph = 1100, className, ...props }: MorphingTextProps) { const reduce = useReducedMotion() const id = React.useId().replace(/:/g, "") const root = React.useRef<HTMLSpanElement>(null) const a = React.useRef<HTMLSpanElement>(null) const b = React.useRef<HTMLSpanElement>(null) const visible = useInView(root) const [announced, setAnnounced] = React.useState(0) React.useEffect(() => { const first = a.current const second = b.current if (!first || !second || texts.length === 0) return first.textContent = texts[0]! first.style.cssText = "opacity:1;filter:blur(0px)" second.textContent = texts[1 % texts.length]! second.style.cssText = "opacity:0;filter:blur(0px)" if (reduce || !visible || texts.length < 2) return let index = 0 let frontIsA = true let start = performance.now() let raf = 0 let holding = true const apply = (el: HTMLElement, fraction: number) => { const f = Math.min(Math.max(fraction, 0.0001), 1) el.style.filter = `blur(${Math.min(8 / f - 8, 100)}px)` el.style.opacity = `${Math.pow(f, 0.4)}` } const frame = (now: number) => { const elapsed = now - start const front = frontIsA ? first : second const back = frontIsA ? second : first if (holding) { if (elapsed >= hold) { holding = false start = now back.textContent = texts[(index + 1) % texts.length]! } } else { const t = Math.min(elapsed / morph, 1) apply(back, t) apply(front, 1 - t) if (t >= 1) { front.style.cssText = "opacity:0;filter:blur(0px)" back.style.cssText = "opacity:1;filter:blur(0px)" frontIsA = !frontIsA index = (index + 1) % texts.length holding = true start = now setAnnounced(index) } } raf = requestAnimationFrame(frame) } raf = requestAnimationFrame(frame) return () => cancelAnimationFrame(raf) }, [texts, hold, morph, reduce, visible]) return ( <span ref={root} data-slot="morphing-text" className={cn("relative inline-grid align-middle text-center font-semibold", className)} style={{ filter: `url(#${id}) blur(0.4px)` }} {...props} > <svg aria-hidden="true" className="pointer-events-none absolute size-0"> <defs> <filter id={id}> <feColorMatrix in="SourceGraphic" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 255 -140" /> </filter> </defs> </svg> {/* Invisible copies reserve the width of the longest text so nothing jumps. */} {texts.map((t, i) => ( <span key={i} aria-hidden="true" className="invisible col-start-1 row-start-1 whitespace-nowrap"> {t} </span> ))} <span ref={a} aria-hidden="true" className="col-start-1 row-start-1 whitespace-nowrap" /> <span ref={b} aria-hidden="true" className="col-start-1 row-start-1 whitespace-nowrap" /> <span className="sr-only">{texts.join(", ")}</span> <span className="sr-only" aria-live="off" data-current={announced} /> </span> ) } export { MorphingText, type MorphingTextProps }Update the import paths to match your project setup.
Usage
import { MorphingText } from "@/components/ballmac/morphing-text"The full example is in the Code tab above.
Examples
Inline in a sentence
Made fordesigners, founders, engineers, everyone
import { MorphingText } from "@/components/ballmac/morphing-text"
export default function MorphingTextInline() {
return (
<p className="flex flex-wrap items-center justify-center gap-x-3 text-3xl font-semibold tracking-tight text-foreground">
<span>Made for</span>
<MorphingText className="h-12 min-w-40 text-3xl" hold={1200} texts={["designers", "founders", "engineers", "everyone"]} />
</p>
)
}API reference
| Prop | Type | Default |
|---|---|---|
texts*The words or phrases to morph between, in order. | string[] | — |
holdMilliseconds each text is held before the next morph starts. | number | 1400 |
morphMilliseconds the morph itself takes. | number | 1100 |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | All texts are read once as a list; the morph is hidden |
| Reduced motion | The first text is shown still |
Use with AI
<MorphingText texts={['Build','Ship','Scale']} hold morph className='text-6xl' />. The size comes from className. Uses an SVG threshold filter; the loop stops when off screen or when reduced motion is on. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Morphing Text (@ballmac/morphing-text) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A hero word that changes between a few options
- Brand or product-name reveals
Not for
- Small body text (the effect needs large type)
- Many words per frame
Registry JSON: https://ui.ballmac.com/r/morphing-text.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Word Rotate
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.
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.
Text Animate
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.
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.