{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morphing-text",
  "title": "Morphing Text",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "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.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/morphing-text.tsx",
      "content": "// Ballmac UI: Morphing Text. https://ui.ballmac.com/components/morphing-text\n\"use client\"\n\nimport * as React from \"react\"\nimport { useInView, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype MorphingTextProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The words or phrases to morph between, in order. */\n  texts: string[]\n  /** Milliseconds each text is held before the next morph starts. */\n  hold?: number\n  /** Milliseconds the morph itself takes. */\n  morph?: number\n}\n\nfunction MorphingText({ texts, hold = 1400, morph = 1100, className, ...props }: MorphingTextProps) {\n  const reduce = useReducedMotion()\n  const id = React.useId().replace(/:/g, \"\")\n  const root = React.useRef<HTMLSpanElement>(null)\n  const a = React.useRef<HTMLSpanElement>(null)\n  const b = React.useRef<HTMLSpanElement>(null)\n  const visible = useInView(root)\n  const [announced, setAnnounced] = React.useState(0)\n\n  React.useEffect(() => {\n    const first = a.current\n    const second = b.current\n    if (!first || !second || texts.length === 0) return\n    first.textContent = texts[0]!\n    first.style.cssText = \"opacity:1;filter:blur(0px)\"\n    second.textContent = texts[1 % texts.length]!\n    second.style.cssText = \"opacity:0;filter:blur(0px)\"\n    if (reduce || !visible || texts.length < 2) return\n\n    let index = 0\n    let frontIsA = true\n    let start = performance.now()\n    let raf = 0\n    let holding = true\n    const apply = (el: HTMLElement, fraction: number) => {\n      const f = Math.min(Math.max(fraction, 0.0001), 1)\n      el.style.filter = `blur(${Math.min(8 / f - 8, 100)}px)`\n      el.style.opacity = `${Math.pow(f, 0.4)}`\n    }\n    const frame = (now: number) => {\n      const elapsed = now - start\n      const front = frontIsA ? first : second\n      const back = frontIsA ? second : first\n      if (holding) {\n        if (elapsed >= hold) {\n          holding = false\n          start = now\n          back.textContent = texts[(index + 1) % texts.length]!\n        }\n      } else {\n        const t = Math.min(elapsed / morph, 1)\n        apply(back, t)\n        apply(front, 1 - t)\n        if (t >= 1) {\n          front.style.cssText = \"opacity:0;filter:blur(0px)\"\n          back.style.cssText = \"opacity:1;filter:blur(0px)\"\n          frontIsA = !frontIsA\n          index = (index + 1) % texts.length\n          holding = true\n          start = now\n          setAnnounced(index)\n        }\n      }\n      raf = requestAnimationFrame(frame)\n    }\n    raf = requestAnimationFrame(frame)\n    return () => cancelAnimationFrame(raf)\n  }, [texts, hold, morph, reduce, visible])\n\n  return (\n    <span\n      ref={root}\n      data-slot=\"morphing-text\"\n      className={cn(\"relative inline-grid align-middle text-center font-semibold\", className)}\n      style={{ filter: `url(#${id}) blur(0.4px)` }}\n      {...props}\n    >\n      <svg aria-hidden=\"true\" className=\"pointer-events-none absolute size-0\">\n        <defs>\n          <filter id={id}>\n            <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\" />\n          </filter>\n        </defs>\n      </svg>\n      {/* Invisible copies reserve the width of the longest text so nothing jumps. */}\n      {texts.map((t, i) => (\n        <span key={i} aria-hidden=\"true\" className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">\n          {t}\n        </span>\n      ))}\n      <span ref={a} aria-hidden=\"true\" className=\"col-start-1 row-start-1 whitespace-nowrap\" />\n      <span ref={b} aria-hidden=\"true\" className=\"col-start-1 row-start-1 whitespace-nowrap\" />\n      <span className=\"sr-only\">{texts.join(\", \")}</span>\n      <span className=\"sr-only\" aria-live=\"off\" data-current={announced} />\n    </span>\n  )\n}\n\nexport { MorphingText, type MorphingTextProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/morphing-text.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "text",
      "morph",
      "gooey",
      "transition",
      "hero"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<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.",
      "whenToUse": [
        "A hero word that changes between a few options",
        "Brand or product-name reveals"
      ],
      "whenNotToUse": [
        "Small body text (the effect needs large type)",
        "Many words per frame"
      ],
      "composesWith": [
        "word-rotate",
        "hyper-text",
        "text-animate"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "All texts are read once as a list; the morph is hidden"
        },
        {
          "keys": "Reduced motion",
          "action": "The first text is shown still"
        }
      ],
      "customization": [
        "texts",
        "hold and morph in milliseconds",
        "font size and weight via className"
      ]
    },
    "examples": [
      "morphing-text-demo",
      "morphing-text-inline"
    ],
    "url": "https://ui.ballmac.com/components/morphing-text"
  },
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}