{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morphing-text",
  "type": "registry:ui",
  "title": "Morphing Text",
  "description": "A headline text animation whose last word cycles through a list with a gooey blur morph — an SVG alpha threshold fuses the outgoing and incoming words into one liquid shape while the slot eases between word widths.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/morphing-text.tsx",
      "type": "registry:component",
      "target": "components/tweenui/morphing-text.tsx",
      "content": "'use client';\n\nimport { useId, useRef, type HTMLAttributes } from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP, ScrollTrigger);\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n// Alpha threshold: soft blurred edges snap to fully opaque or fully clear, so\n// the two overlapping blurred words fuse into one liquid shape mid-morph.\nconst THRESHOLD_MATRIX = '1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 255 -140';\n\nconst ALIGN = { left: 'text-left', center: 'text-center', right: 'text-right' } as const;\n\ntype MorphingTextTag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span';\n\nexport interface MorphingTextProps extends HTMLAttributes<HTMLElement> {\n  /** Words to cycle through. The first one is what renders on the server. */\n  words: string[];\n  /** Text before the morphing word. `\\n` starts a new line. */\n  prefix?: string;\n  /** Element to render. Defaults to `h2` with a prefix, `span` without. */\n  as?: MorphingTextTag;\n  /** Length of each morph, in seconds. */\n  morphDuration?: number;\n  /** How long each word rests before morphing, in seconds. */\n  pauseDuration?: number;\n  /** Blur ceiling while a word is fully dissolved, in pixels. */\n  maxBlur?: number;\n  /**\n   * Keep the slot at the first word's width so nearby text never moves; longer\n   * words overflow it. Off, GSAP eases the slot between each word's width.\n   */\n  fixedWidth?: boolean;\n  /** Alignment of the word inside its slot. */\n  wordAlign?: keyof typeof ALIGN;\n  /** Extra classes for the morphing word — color, weight, italics. */\n  wordClassName?: string;\n}\n\nconst MorphingText = ({\n  words,\n  prefix,\n  as,\n  morphDuration = 1.2,\n  pauseDuration = 1.8,\n  maxBlur = 16,\n  fixedWidth = false,\n  wordAlign = 'left',\n  wordClassName,\n  className,\n  ...props\n}: MorphingTextProps) => {\n  const slotRef = useRef<HTMLSpanElement>(null);\n  const layerRef = useRef<HTMLSpanElement>(null);\n  const aRef = useRef<HTMLSpanElement>(null);\n  const bRef = useRef<HTMLSpanElement>(null);\n  const measureRefs = useRef<(HTMLSpanElement | null)[]>([]);\n\n  // useId can contain characters that are not valid inside `url(#…)`.\n  const filterId = `morph-threshold-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;\n  const wordsKey = words.join('|');\n\n  useGSAP(\n    () => {\n      const slot = slotRef.current;\n      const layer = layerRef.current;\n      const a = aRef.current;\n      const b = bRef.current;\n      if (!slot || !layer || !a || !b || !words.length) return;\n\n      const count = words.length;\n      let widths: number[] = [];\n      let current = 0;\n      let disposed = false;\n\n      const widthFor = (i: number) => widths[fixedWidth ? 0 : i] ?? 0;\n\n      // Measure every word so the slot can ease between their widths. Only\n      // touch the slot when a width really changed — a zero width means the\n      // text is not laid out yet (display: none), so leave it on auto.\n      const fit = () => {\n        const next = measureRefs.current\n          .slice(0, count)\n          .map((el) => (el ? el.getBoundingClientRect().width : 0));\n        const same =\n          next.length === widths.length && next.every((w, i) => Math.abs(w - widths[i]) < 0.5);\n        if (same) return;\n        widths = next;\n        const w = widthFor(current);\n        if (w) gsap.set(slot, { width: w });\n      };\n\n      fit();\n      document.fonts?.ready.then(() => {\n        if (!disposed) fit();\n      });\n      const ro = new ResizeObserver(fit);\n      ro.observe(slot.closest('[data-morphing-text]') ?? slot);\n\n      const rest = (word: string) => {\n        a.textContent = word;\n        a.style.filter = '';\n        a.style.opacity = '';\n        b.textContent = '';\n        b.style.filter = '';\n        b.style.opacity = '0';\n        // Crisp text at rest; the threshold only runs during the morph.\n        layer.style.filter = '';\n        // Promote only while morphing — a permanent compositing layer under an\n        // SVG filter can rasterise blank on some Windows GPUs.\n        a.style.willChange = '';\n        b.style.willChange = '';\n      };\n\n      const cleanup = () => {\n        disposed = true;\n        ro.disconnect();\n        rest(words[0]);\n      };\n\n      if (count < 2 || prefersReducedMotion()) return cleanup;\n\n      const blurFor = (f: number) => (f <= 0 ? maxBlur : Math.min(8 / f - 8, maxBlur));\n      const morph = { f: 0 };\n      const setFrame = () => {\n        const f = morph.f;\n        a.style.filter = `blur(${blurFor(1 - f)}px)`;\n        a.style.opacity = String(Math.pow(1 - f, 0.4));\n        b.style.filter = `blur(${blurFor(f)}px)`;\n        b.style.opacity = String(Math.pow(f, 0.4));\n      };\n\n      const tl = gsap.timeline({ paused: true, repeat: -1 });\n\n      for (let i = 0; i < count; i++) {\n        const next = (i + 1) % count;\n\n        tl.call(\n          () => {\n            a.textContent = words[i];\n            b.textContent = words[next];\n            a.style.willChange = 'filter, opacity';\n            b.style.willChange = 'filter, opacity';\n            layer.style.filter = `url(#${filterId}) blur(0.6px)`;\n            current = next;\n            // Read the width when the morph starts, not when the timeline was\n            // built, so a resize or late font load is always picked up.\n            const w = widthFor(next);\n            if (!fixedWidth && w) {\n              gsap.to(slot, {\n                width: w,\n                duration: morphDuration,\n                ease: 'power2.inOut',\n                overwrite: true,\n              });\n            }\n          },\n          undefined,\n          `+=${Math.max(0, pauseDuration)}`\n        );\n        tl.fromTo(\n          morph,\n          { f: 0 },\n          {\n            f: 1,\n            duration: Math.max(0.1, morphDuration),\n            ease: 'none',\n            immediateRender: false,\n            onUpdate: setFrame,\n          }\n        );\n        tl.call(() => rest(words[next]));\n      }\n\n      // Only cycle while the headline is on screen.\n      ScrollTrigger.create({\n        trigger: slot,\n        start: 'top bottom',\n        end: 'bottom top',\n        onToggle: (self) => (self.isActive ? tl.play() : tl.pause()),\n      });\n\n      return cleanup;\n    },\n    {\n      scope: slotRef,\n      dependencies: [wordsKey, morphDuration, pauseDuration, maxBlur, fixedWidth, filterId],\n      revertOnUpdate: true,\n    }\n  );\n\n  const Tag = as ?? (prefix ? 'h2' : 'span');\n\n  // Earlier prefix lines wrap freely; the last line stays glued to the word so\n  // a wider word never drops onto a line of its own mid-cycle.\n  const lines = (prefix ?? '').trim().split('\\n');\n  const lastLine = lines.pop() ?? '';\n\n  const wordStyle = cn('absolute top-0 left-0 w-full whitespace-nowrap', ALIGN[wordAlign]);\n\n  const slot = (\n    <span\n      ref={slotRef}\n      className={cn('relative inline-block align-bottom whitespace-nowrap', wordClassName)}\n    >\n      {/* Screen readers get the first word once, not a changing headline. */}\n      <span className=\"sr-only\">{words[0]}</span>\n\n      {/* Invisible copy in normal flow gives the slot its height and baseline. */}\n      <span aria-hidden=\"true\" className=\"invisible\">\n        {words[0]}\n      </span>\n\n      <span\n        ref={layerRef}\n        aria-hidden=\"true\"\n        className=\"absolute inset-0 motion-reduce:filter-none\"\n      >\n        {/* Keyed so a new word list remounts the spans the loop has been writing to. */}\n        <span key={`a-${wordsKey}`} ref={aRef} className={wordStyle}>\n          {words[0]}\n        </span>\n        <span key={`b-${wordsKey}`} ref={bRef} className={wordStyle} style={{ opacity: 0 }} />\n      </span>\n\n      {/* Hidden measuring copies, one per word. */}\n      <span aria-hidden=\"true\" className=\"pointer-events-none invisible absolute top-0 left-0\">\n        {words.map((word, i) => (\n          <span\n            key={`${word}-${i}`}\n            ref={(el) => {\n              measureRefs.current[i] = el;\n            }}\n            className=\"absolute whitespace-nowrap\"\n          >\n            {word}\n          </span>\n        ))}\n      </span>\n\n      <svg\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-0 left-0 size-px overflow-hidden opacity-0\"\n      >\n        <defs>\n          <filter id={filterId}>\n            <feColorMatrix in=\"SourceGraphic\" type=\"matrix\" values={THRESHOLD_MATRIX} />\n          </filter>\n        </defs>\n      </svg>\n    </span>\n  );\n\n  return (\n    <Tag\n      data-morphing-text\n      className={cn('relative m-0', prefix ? 'block' : 'inline-block', className)}\n      {...props}\n    >\n      {lines.map((line, i) => (\n        <span key={i}>\n          {line}\n          <br />\n        </span>\n      ))}\n      {prefix ? (\n        <span className=\"whitespace-nowrap\">\n          {lastLine} {slot}\n        </span>\n      ) : (\n        slot\n      )}\n    </Tag>\n  );\n};\n\nexport default MorphingText;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/component/morphing-text\nGSAP plugins (ScrollTrigger) are registered inside the component, so there is nothing to set up."
}