{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-text",
  "type": "registry:component",
  "title": "Morph Text",
  "description": "Words melt into one another with a viscous blur-and-threshold transition and a liquid sheen.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Text"
  ],
  "docs": "Installed in components/furin/morph-text/. Docs, live controls and props table: https://furin.dev/components/morph-text/",
  "files": [
    {
      "path": "registry/morph-text/MorphText.tsx",
      "content": "\"use client\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./MorphText.css\";\n\nexport interface MorphTextProps {\n  /** Palabras que se van transformando en ciclo. */\n  words?: string[];\n  /** Segundos que se queda quieta cada palabra. */\n  interval?: number;\n  /** Segundos que dura el derretido entre palabras. */\n  morph?: number;\n  /** Tamaño máximo de la letra, en px (se ajusta al ancho). */\n  fontSize?: number;\n  /** Grosor de la letra. */\n  fontWeight?: number;\n  /** Color del líquido. */\n  color?: string;\n  /** Intensidad del brillo especular (0 lo apaga). */\n  gloss?: number;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst DEFAULT_WORDS = [\"Flow\", \"Morph\", \"Shine\", \"Drip\"];\n\n/** Texto que muta: dos palabras se desenfocan y se funden pasando por un umbral, como gotas de mercurio que se unen. */\nexport function MorphText({\n  words = DEFAULT_WORDS,\n  interval = 1.8,\n  morph = 1.5,\n  fontSize = 104,\n  fontWeight = 800,\n  color = \"#A9C8FF\",\n  gloss = 1,\n  className = \"\",\n  style,\n}: MorphTextProps) {\n  const uid = useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  const filterId = `furin-morph-${uid}`;\n  const host = useRef<HTMLDivElement>(null);\n  const refA = useRef<HTMLSpanElement>(null);\n  const refB = useRef<HTMLSpanElement>(null);\n  const [fs, setFs] = useState(fontSize);\n  const [shown, setShown] = useState(words[0] ?? \"\");\n  const list = words.length ? words : DEFAULT_WORDS;\n  const key = list.join(\"\\u0001\");\n\n  // Ajusta el tamaño para que la palabra más larga quepa.\n  useEffect(() => {\n    const el = host.current;\n    if (!el) return;\n    const measure = () => {\n      const ctx = document.createElement(\"canvas\").getContext(\"2d\")!;\n      ctx.font = `${fontWeight} 100px ${getComputedStyle(el).fontFamily}`;\n      const w100 = Math.max(...list.map((w) => ctx.measureText(w).width), 1);\n      setFs(Math.max(24, Math.min(fontSize, (el.clientWidth * 0.9) / (w100 / 100))));\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    document.fonts?.ready.then(measure);\n    return () => ro.disconnect();\n  }, [key, fontSize, fontWeight]);\n\n  useEffect(() => {\n    const a = refA.current;\n    const b = refB.current;\n    const el = host.current;\n    if (!a || !b || !el) return;\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let idx = 0;\n    a.textContent = list[0];\n    b.textContent = list[1 % list.length];\n    a.style.filter = \"\";\n    a.style.opacity = \"1\";\n    b.style.filter = \"\";\n    b.style.opacity = \"0\";\n    setShown(list[0]);\n    if (reduce || list.length < 2) return;\n\n    let visible = true;\n    let raf = 0;\n    let timer = 0;\n    let t0 = 0;\n    const cap = 42; // desenfoque máximo (en unidades de 100 px de letra)\n    const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);\n\n    const setFrame = (f: number) => {\n      // Cada palabra se desenfoca hacia el umbral mientras la otra se enfoca.\n      const fa = 1 - f;\n      const ba = Math.min(8 / Math.max(fa, 0.001) - 8, cap);\n      const bb = Math.min(8 / Math.max(f, 0.001) - 8, cap);\n      a.style.filter = `blur(${(ba * fs) / 100}px)`;\n      a.style.opacity = String(Math.pow(fa, 0.4));\n      b.style.filter = `blur(${(bb * fs) / 100}px)`;\n      b.style.opacity = String(Math.pow(f, 0.4));\n    };\n    const finish = () => {\n      idx = (idx + 1) % list.length;\n      a.textContent = list[idx];\n      b.textContent = list[(idx + 1) % list.length];\n      a.style.filter = \"\";\n      a.style.opacity = \"1\";\n      b.style.filter = \"\";\n      b.style.opacity = \"0\";\n      setShown(list[idx]);\n    };\n    const run = (now: number) => {\n      raf = 0;\n      if (!visible) return;\n      if (!t0) t0 = now;\n      const f = Math.min((now - t0) / (morph * 1000), 1);\n      if (f >= 1) {\n        finish();\n        t0 = 0;\n        timer = window.setTimeout(start, interval * 1000);\n        return;\n      }\n      setFrame(ease(f));\n      raf = requestAnimationFrame(run);\n    };\n    const start = () => {\n      if (!visible) return;\n      t0 = 0;\n      raf = requestAnimationFrame(run);\n    };\n    timer = window.setTimeout(start, interval * 1000);\n\n    // Pausa fuera de pantalla: el ciclo se reanuda con una pausa nueva.\n    const io = new IntersectionObserver(([en]) => {\n      visible = en.isIntersecting;\n      if (!visible) {\n        cancelAnimationFrame(raf);\n        window.clearTimeout(timer);\n        raf = 0;\n        if (t0) {\n          // Si se pausó a la mitad, cierra el derretido sin saltos.\n          finish();\n          t0 = 0;\n        }\n      } else if (!raf) {\n        window.clearTimeout(timer);\n        timer = window.setTimeout(start, interval * 1000);\n      }\n    });\n    io.observe(el);\n    return () => {\n      cancelAnimationFrame(raf);\n      window.clearTimeout(timer);\n      io.disconnect();\n    };\n  }, [key, interval, morph, fs]);\n\n  const g = Math.max(0, gloss);\n  const vars = { \"--furin-mt-size\": `${fs}px`, \"--furin-mt-weight\": fontWeight, \"--furin-mt-color\": color } as CSSProperties;\n  return (\n    <div ref={host} className={`furin-morph-text ${className}`} style={{ ...vars, ...style }}>\n      <span className=\"furin-morph-text__sr\" aria-live=\"polite\">{shown}</span>\n      <div className=\"furin-morph-text__goo\" aria-hidden=\"true\" style={{ filter: `url(#${filterId})` }}>\n        <span ref={refA} className=\"furin-morph-text__palabra furin-morph-text__palabra--a\" />\n        <span ref={refB} className=\"furin-morph-text__palabra furin-morph-text__palabra--b\" />\n      </div>\n      <svg className=\"furin-morph-text__svg\" aria-hidden=\"true\" focusable=\"false\">\n        <defs>\n          <filter id={filterId} x=\"-15%\" y=\"-40%\" width=\"130%\" height=\"180%\" colorInterpolationFilters=\"sRGB\">\n            {/* Umbral: lo desenfocado por debajo de la mitad desaparece y lo demás se solidifica. */}\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 56 -24\" result=\"umbral\" />\n            <feGaussianBlur in=\"umbral\" stdDeviation=\"0.6\" result=\"forma\" />\n            <feGaussianBlur in=\"forma\" stdDeviation=\"3.4\" result=\"relieve\" />\n            <feDiffuseLighting in=\"relieve\" surfaceScale=\"7\" diffuseConstant=\"1.18\" lightingColor=\"#ffffff\" result=\"difusa\">\n              <feDistantLight azimuth=\"235\" elevation=\"52\" />\n            </feDiffuseLighting>\n            <feComposite in=\"forma\" in2=\"difusa\" operator=\"arithmetic\" k1=\"1\" k2=\"0\" k3=\"0\" k4=\"0\" result=\"sombreada\" />\n            <feSpecularLighting in=\"relieve\" surfaceScale=\"8\" specularConstant={1.15 * g} specularExponent=\"30\" lightingColor=\"#ffffff\" result=\"brillo\">\n              <feDistantLight azimuth=\"235\" elevation=\"46\" />\n            </feSpecularLighting>\n            <feComposite in=\"brillo\" in2=\"forma\" operator=\"in\" result=\"brilloDentro\" />\n            <feComposite in=\"sombreada\" in2=\"brilloDentro\" operator=\"arithmetic\" k1=\"0\" k2=\"1\" k3=\"1\" k4=\"0\" />\n          </filter>\n        </defs>\n      </svg>\n    </div>\n  );\n}\n\nexport default MorphText;\n",
      "type": "registry:component",
      "target": "components/furin/morph-text/MorphText.tsx"
    },
    {
      "path": "registry/morph-text/MorphText.css",
      "content": ".furin-morph-text {\n  position: relative;\n  display: block;\n  width: min(100%, 560px);\n  height: calc(var(--furin-mt-size, 96px) * 1.55);\n  font-size: var(--furin-mt-size, 96px);\n  font-weight: var(--furin-mt-weight, 800);\n  line-height: 1;\n  letter-spacing: -0.02em;\n  color: var(--furin-mt-color, #a9c8ff);\n  text-align: center;\n  user-select: none;\n  -webkit-user-select: none;\n}\n.furin-morph-text__goo { position: absolute; inset: 0; }\n.furin-morph-text__palabra {\n  position: absolute; left: 0; right: 0; top: 50%;\n  transform: translateY(-50%);\n  white-space: nowrap;\n  will-change: filter, opacity;\n  /* Degradado de líquido: luz arriba, cuerpo al centro y sombra abajo; el relieve lo pone el filtro. */\n  background: linear-gradient(180deg, #fff 8%, var(--furin-mt-color, #a9c8ff) 42%, color-mix(in srgb, var(--furin-mt-color, #a9c8ff) 45%, #0b0c0f) 100%);\n  -webkit-background-clip: text;\n  background-clip: text;\n  color: transparent;\n  -webkit-text-fill-color: transparent;\n}\n.furin-morph-text__palabra--b { opacity: 0; }\n.furin-morph-text__svg { position: absolute; width: 0; height: 0; pointer-events: none; }\n.furin-morph-text__sr {\n  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;\n  clip: rect(0 0 0 0); white-space: nowrap; border: 0;\n}\n",
      "type": "registry:file",
      "target": "components/furin/morph-text/MorphText.css"
    }
  ]
}
