{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-text",
  "type": "registry:ui",
  "title": "Morph Text",
  "description": "Words that melt into one another through a liquid blur-and-threshold morph.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/morph-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface MorphTextProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** Words (or short phrases) to cycle through, in order. */\n  words?: string[];\n  /** Element to render. */\n  as?: \"span\" | \"p\" | \"h1\" | \"h2\" | \"h3\" | \"div\";\n  /** Duration of one morph, in ms. */\n  morphTime?: number;\n  /** How long each word holds before the next morph, in ms. */\n  cooldownTime?: number;\n  /** Blur strength of the morph; higher melts the letterforms further. */\n  blur?: number;\n  /** Alpha cut-off of the threshold filter (0–255); lower makes the liquid bridges thicker. */\n  threshold?: number;\n  /** Stop cycling and hold the current word. */\n  paused?: boolean;\n  /** Horizontal alignment of words of different widths. */\n  align?: \"left\" | \"center\" | \"right\";\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\nconst DEFAULT_WORDS = [\"Craft\", \"Motion\", \"Detail\", \"Intent\"];\n\nconst ALIGN = {\n  left: \"justify-items-start text-left\",\n  center: \"justify-items-center text-center\",\n  right: \"justify-items-end text-right\",\n} as const;\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * Words that melt into each other. Two stacked copies cross-blur while an SVG filter\n * (feColorMatrix alpha threshold) snaps the blurred alpha back to hard edges, so the letterforms\n * appear to flow like liquid. Driven by one rAF loop that pauses offscreen and in hidden tabs.\n */\nexport function MorphText({\n  words = DEFAULT_WORDS,\n  as = \"span\",\n  morphTime = 1000,\n  cooldownTime = 1800,\n  blur = 8,\n  threshold = 140,\n  paused = false,\n  align = \"center\",\n  className = \"\",\n  ...props\n}: MorphTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const aRef = React.useRef<HTMLSpanElement>(null);\n  const bRef = React.useRef<HTMLSpanElement>(null);\n  const stackRef = React.useRef<HTMLSpanElement>(null);\n  const filterId = `morph-${React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const Tag = as as React.ElementType;\n\n  const list = words.length ? words : [\"\"];\n  const wordsKey = list.join(\"\\u0000\");\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const a = aRef.current; // outgoing word\n    const b = bRef.current; // incoming word\n    const stack = stackRef.current;\n    if (!root || !a || !b || !stack) return;\n    // The threshold filter only runs during a morph; at rest the text keeps its normal anti-aliasing.\n    const liquid = (on: boolean) => {\n      stack.style.filter = on && !reduceMotion ? `url(#${filterId}) blur(0.6px)` : \"\";\n    };\n    const items = wordsKey.split(\"\\u0000\");\n    const n = items.length;\n\n    let index = 0;\n    let morph = 0;\n    let cooldown = cooldownTime;\n    let frame = 0;\n    let visible = true;\n    let last = performance.now();\n\n    const rest = () => {\n      // Hold: incoming word fully shown, outgoing hidden.\n      morph = 0;\n      b.style.filter = \"\";\n      b.style.opacity = \"1\";\n      a.style.filter = \"\";\n      a.style.opacity = \"0\";\n      liquid(false);\n    };\n\n    const setMorph = (fraction: number) => {\n      if (reduceMotion) {\n        // Plain crossfade, no blur and no threshold.\n        b.style.opacity = String(fraction);\n        a.style.opacity = String(1 - fraction);\n        return;\n      }\n      const inv = 1 - fraction;\n      b.style.filter = `blur(${Math.min(blur / Math.max(fraction, 1e-3) - blur, 100)}px)`;\n      b.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`;\n      a.style.filter = `blur(${Math.min(blur / Math.max(inv, 1e-3) - blur, 100)}px)`;\n      a.style.opacity = `${Math.pow(inv, 0.4) * 100}%`;\n    };\n\n    a.textContent = items[0];\n    b.textContent = items[0];\n    rest();\n\n    const tick = (now: number) => {\n      const dt = Math.min(64, now - last);\n      last = now;\n      const wasCooling = cooldown > 0;\n      cooldown -= dt;\n      if (cooldown <= 0) {\n        if (wasCooling) {\n          // Start the next morph: current word fades out of A, next word forms in B.\n          a.textContent = items[index % n];\n          index = (index + 1) % n;\n          b.textContent = items[index];\n          liquid(true);\n        }\n        morph += dt;\n        let fraction = morph / Math.max(1, morphTime);\n        if (fraction >= 1) {\n          cooldown = cooldownTime;\n          fraction = 1;\n        }\n        setMorph(fraction);\n        if (fraction === 1) rest();\n      }\n      frame = requestAnimationFrame(tick);\n    };\n\n    const sync = () => {\n      const run = visible && !document.hidden && !paused && n > 1;\n      if (run && !frame) {\n        last = performance.now();\n        frame = requestAnimationFrame(tick);\n      } else if (!run && frame) {\n        cancelAnimationFrame(frame);\n        frame = 0;\n      }\n    };\n\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      sync();\n    });\n    io.observe(root);\n    document.addEventListener(\"visibilitychange\", sync);\n    sync();\n\n    return () => {\n      cancelAnimationFrame(frame);\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", sync);\n      liquid(false);\n    };\n  }, [filterId, wordsKey, morphTime, cooldownTime, blur, paused, reduceMotion]);\n\n  const layer = \"col-start-1 row-start-1 whitespace-nowrap will-change-[filter,opacity]\";\n\n  return (\n    <Tag ref={rootRef} className={`relative inline-block ${className}`} {...props}>\n      <span className=\"sr-only\">{list.join(\", \")}</span>\n      <span ref={stackRef} aria-hidden=\"true\" className={`grid select-none ${ALIGN[align] ?? ALIGN.center}`}>\n        {/* Every word, invisible, reserves the width of the longest one so nothing reflows. */}\n        {list.map((word, i) => (\n          <span key={i} className=\"invisible col-start-1 row-start-1 whitespace-nowrap\">\n            {word}\n          </span>\n        ))}\n        <span ref={aRef} className={layer} style={{ opacity: 0 }}>\n          {list[0]}\n        </span>\n        <span ref={bRef} className={layer}>\n          {list[0]}\n        </span>\n      </span>\n      <svg aria-hidden=\"true\" focusable=\"false\" className=\"absolute h-0 w-0\">\n        <defs>\n          <filter id={filterId}>\n            {/* Hard alpha threshold: blurred edges snap back to crisp, merged shapes. */}\n            <feColorMatrix\n              in=\"SourceGraphic\"\n              type=\"matrix\"\n              values={`1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 255 -${Math.max(1, Math.min(254, Math.round(threshold)))}`}\n            />\n          </filter>\n        </defs>\n      </svg>\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/morph-text"
}
