{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morph-text","type":"registry:ui","title":"Morph Text","description":"Cycles through words that melt into one another through a gooey blur-and-threshold crossfade.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/morph-text.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties } from \"react\";\n\nexport interface MorphTextProps {\n  /** Words to cycle through. */\n  words: string[];\n  /** Morph duration, in ms. */\n  duration?: number;\n  /** How long each word rests, in ms. */\n  pause?: number;\n  className?: string;\n}\n\nconst piece: CSSProperties = { gridArea: \"1 / 1\", whiteSpace: \"nowrap\", userSelect: \"none\" };\n\nexport function MorphText({ words, duration = 1100, pause = 1600, className }: MorphTextProps) {\n  const box = useRef<HTMLSpanElement>(null);\n  const a = useRef<HTMLSpanElement>(null);\n  const b = useRef<HTMLSpanElement>(null);\n  const [index, setIndex] = useState(0);\n  const id = `mt${useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n  const n = Math.max(words.length, 1);\n  const count = words.length;\n\n  // Called with f in [0, 1]: 0 shows the current word, 1 the next one.\n  const apply = (f: number) => {\n    if (!box.current || !a.current || !b.current) return;\n    const blur = (x: number) => (x <= 0 ? 100 : Math.min(8 / x - 8, 100));\n    b.current.style.filter = `blur(${blur(f)}px)`;\n    b.current.style.opacity = String(Math.pow(f, 0.4));\n    a.current.style.filter = `blur(${blur(1 - f)}px)`;\n    a.current.style.opacity = String(Math.pow(1 - f, 0.4));\n    box.current.style.filter = f > 0 && f < 1 ? `url(#${id})` : \"\";\n  };\n  const applyRef = useRef(apply);\n  useLayoutEffect(() => {\n    applyRef.current = apply;\n    apply(0);\n  });\n\n  useEffect(() => {\n    const el = box.current;\n    if (!el || count < 2) return;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let raf = 0;\n    let start = 0;\n    const tick = (now: number) => {\n      raf = requestAnimationFrame(tick);\n      if (!start) start = now;\n      const t = now - start - pause;\n      if (t < 0) return;\n      const f = reduced ? 1 : Math.min(t / duration, 1);\n      applyRef.current(f);\n      if (f >= 1) {\n        start = now;\n        setIndex((i) => (i + 1) % count);\n      }\n    };\n    const io = new IntersectionObserver(([e]) => {\n      cancelAnimationFrame(raf);\n      start = 0;\n      applyRef.current(0);\n      if (e.isIntersecting) raf = requestAnimationFrame(tick);\n    });\n    io.observe(el);\n    return () => {\n      io.disconnect();\n      cancelAnimationFrame(raf);\n    };\n  }, [count, duration, pause]);\n\n  const current = words[index % n] ?? \"\";\n  const next = words[(index + 1) % n] ?? \"\";\n\n  return (\n    <span className={className} style={{ display: \"inline-block\" }}>\n      <svg aria-hidden width=\"0\" height=\"0\" style={{ position: \"absolute\" }}>\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      </svg>\n      <span className=\"sr-only\">{words.join(\", \")}</span>\n      <span ref={box} aria-hidden style={{ display: \"inline-grid\", justifyItems: \"center\" }}>\n        <span ref={a} style={piece}>\n          {current}\n        </span>\n        <span ref={b} style={{ ...piece, opacity: 0 }}>\n          {next}\n        </span>\n      </span>\n    </span>\n  );\n}\n"}]}