{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-text-morph",
  "title": "Text Morph",
  "description": "Animate changing text with shared letters that move into place, soft arrivals, and a smoothly resizing width.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/components/text-morph.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * TEXT MORPH: text that changes the way it should\n *\n *   shared   letters both texts share stay alive and slide to\n *            their new place (\"Save\" → \"Saving\" keeps \"Sav\")\n *   new      letters that weren't there rise in out of a blur,\n *            one after another\n *   gone     letters that leave lift away and blur out where\n *            they stood\n *   width    the box eases to its new width, so a button or\n *            pill holding it resizes with it\n *\n * Texts with little in common crossfade as a whole instead of\n * flinging single letters across. The real text is always\n * there for screen readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface TextMorphProps extends Omit<\n  React.HTMLAttributes<HTMLSpanElement>,\n  \"children\"\n> {\n  children: string;\n  /** Morph time in ms. */\n  duration?: number;\n  /** Where new letters come from: below (\"up\") or above (\"down\"). */\n  direction?: \"up\" | \"down\";\n  /** Ease the box's width too; turn off for text that wraps. */\n  animateWidth?: boolean;\n}\n\ntype Glyph = { key: number; char: string };\ntype Leaving = Glyph & { x: number; y: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\n/** Keep the letters two texts share (longest common subsequence); everything else is new. */\nfunction morph(\n  previous: Glyph[],\n  text: string,\n  nextKey: () => number,\n): Glyph[] {\n  const a = previous.map((g) => g.char);\n  const b = Array.from(text);\n  const fresh = () => b.map((char) => ({ key: nextKey(), char }));\n  if (!a.length || !b.length) return fresh();\n\n  const dp = Array.from(\n    { length: a.length + 1 },\n    () => new Uint16Array(b.length + 1),\n  );\n  for (let i = a.length - 1; i >= 0; i--)\n    for (let j = b.length - 1; j >= 0; j--)\n      dp[i][j] =\n        a[i] === b[j]\n          ? dp[i + 1][j + 1] + 1\n          : Math.max(dp[i + 1][j], dp[i][j + 1]);\n\n  // Too little in common reads as two different words: crossfade them instead.\n  if (dp[0][0] < Math.min(a.length, b.length) * 0.5) return fresh();\n\n  const out: Glyph[] = [];\n  let i = 0;\n  let j = 0;\n  while (j < b.length) {\n    if (i < a.length && a[i] === b[j]) {\n      out.push(previous[i++]);\n      j++;\n    } else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) i++;\n    else out.push({ key: nextKey(), char: b[j++] });\n  }\n  return out;\n}\n\nfunction LeavingGlyph({\n  glyph,\n  lift,\n  duration,\n  onDone,\n}: {\n  glyph: Leaving;\n  lift: number;\n  duration: number;\n  onDone: (key: number) => void;\n}) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const animation = el.animate(\n      [\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n        { opacity: 0, filter: \"blur(4px)\", transform: `translateY(${lift}em)` },\n      ],\n      { duration: duration * 0.6, easing: EASE, fill: \"forwards\" },\n    );\n    animation.onfinish = () => onDone(glyph.key);\n    return () => animation.cancel();\n  }, [glyph.key, lift, duration, onDone]);\n  return (\n    <span\n      ref={ref}\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute whitespace-pre\"\n      style={{ left: glyph.x, top: glyph.y }}\n    >\n      {glyph.char}\n    </span>\n  );\n}\n\nexport function TextMorph({\n  children: text,\n  duration = 460,\n  direction = \"up\",\n  animateWidth = true,\n  className = \"\",\n  ...props\n}: TextMorphProps) {\n  const reduced = useReducedMotion();\n  const counter = React.useRef(text.length);\n  const nextKey = React.useCallback(() => ++counter.current, []);\n  const [glyphs, setGlyphs] = React.useState<Glyph[]>(() =>\n    Array.from(text).map((char, i) => ({ key: i + 1, char })),\n  );\n  const [leaving, setLeaving] = React.useState<Leaving[]>([]);\n  const rootRef = React.useRef<HTMLSpanElement>(null);\n  const els = React.useRef(new Map<number, HTMLSpanElement>());\n  const before = React.useRef<{\n    at: Map<number, { x: number; y: number }>;\n    width: number;\n  } | null>(null);\n  const rise = direction === \"up\" ? 0.35 : -0.35;\n\n  // A preference change must also finish an in-flight morph whose target text\n  // already matches the glyphs (including the empty label in a folded receipt).\n  React.useLayoutEffect(() => {\n    if (!reduced) return;\n    before.current = null;\n    const root = rootRef.current;\n    root\n      ?.getAnimations({ subtree: true })\n      .forEach((animation) => animation.cancel());\n    if (root) root.style.whiteSpace = \"\";\n    setLeaving((previous) => (previous.length ? [] : previous));\n  }, [reduced]);\n\n  // 1. The text changed: note where every letter stands, then work out which letters stay.\n  React.useLayoutEffect(() => {\n    if (text === glyphs.map((g) => g.char).join(\"\")) return;\n    const root = rootRef.current;\n    if (!root || reduced) {\n      setLeaving([]);\n      setGlyphs(Array.from(text).map((char) => ({ key: nextKey(), char })));\n      return;\n    }\n    const box = root.getBoundingClientRect();\n    const at = new Map<number, { x: number; y: number }>();\n    els.current.forEach((el, key) => {\n      const r = el.getBoundingClientRect();\n      at.set(key, { x: r.left - box.left, y: r.top - box.top });\n    });\n    before.current = { at, width: box.width };\n    const next = morph(glyphs, text, nextKey);\n    const kept = new Set(next.map((g) => g.key));\n    setLeaving((l) => [\n      ...l,\n      ...glyphs\n        .filter((g) => !kept.has(g.key))\n        .map((g) => ({ ...g, ...(at.get(g.key) ?? { x: 0, y: 0 }) })),\n    ]);\n    setGlyphs(next);\n  }, [text, reduced]);\n\n  // 2. The new letters are in place: glide the ones that stayed, raise the new ones, ease the width.\n  React.useLayoutEffect(() => {\n    const prev = before.current;\n    const root = rootRef.current;\n    if (!prev || !root) return;\n    before.current = null;\n    const box = root.getBoundingClientRect();\n    let order = 0;\n    for (const g of glyphs) {\n      const el = els.current.get(g.key);\n      if (!el) continue;\n      const from = prev.at.get(g.key);\n      if (from) {\n        const r = el.getBoundingClientRect();\n        const dx = from.x - (r.left - box.left);\n        const dy = from.y - (r.top - box.top);\n        if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)\n          el.animate(\n            [\n              { transform: `translate(${dx}px, ${dy}px)` },\n              { transform: \"none\" },\n            ],\n            { duration, easing: EASE },\n          );\n      } else {\n        el.animate(\n          [\n            {\n              opacity: 0,\n              filter: \"blur(4px)\",\n              transform: `translateY(${rise}em)`,\n            },\n            { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n          ],\n          {\n            duration,\n            easing: EASE,\n            delay: Math.min(order++ * 16, 160),\n            fill: \"backwards\",\n          },\n        );\n      }\n    }\n    if (animateWidth && Math.abs(prev.width - box.width) > 0.5) {\n      // Hold the words on one line while the box is narrower than them, or \"No credits left\" would wrap mid-morph.\n      root.style.whiteSpace = \"nowrap\";\n      const resize = root.animate(\n        [{ width: `${prev.width}px` }, { width: `${box.width}px` }],\n        { duration, easing: EASE },\n      );\n      resize.onfinish = resize.oncancel = () => {\n        root.style.whiteSpace = \"\";\n      };\n    }\n  }, [glyphs, duration, rise, animateWidth]);\n\n  const done = React.useCallback(\n    (key: number) => setLeaving((l) => l.filter((g) => g.key !== key)),\n    [],\n  );\n\n  // Words stay unbroken; lines may only wrap at spaces.\n  const words: Glyph[][] = [];\n  for (const g of glyphs) {\n    if (\n      g.char === \" \" ||\n      !words.length ||\n      words[words.length - 1][0]?.char === \" \"\n    )\n      words.push([g]);\n    else words[words.length - 1].push(g);\n  }\n\n  return (\n    <span\n      ref={rootRef}\n      className={`relative inline-block ${className}`}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      {words.map((word, w) => (\n        <span\n          key={w}\n          aria-hidden=\"true\"\n          className=\"inline-block whitespace-pre\"\n        >\n          {word.map((g) => (\n            <span\n              key={g.key}\n              ref={(el) => {\n                if (el) els.current.set(g.key, el);\n                else els.current.delete(g.key);\n              }}\n              className=\"inline-block\"\n            >\n              {g.char}\n            </span>\n          ))}\n        </span>\n      ))}\n      {leaving.map((g) => (\n        <LeavingGlyph\n          key={`out-${g.key}`}\n          glyph={g}\n          lift={-rise}\n          duration={duration}\n          onDone={done}\n        />\n      ))}\n    </span>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "license": "MIT",
    "cavemio": {
      "component": "text-morph",
      "exportName": "TextMorph",
      "usage": "import { TextMorph } from \"@/components/text-morph\";\n\n<TextMorph>{saved ? \"Saved\" : \"Save\"}</TextMorph>",
      "accessibility": "The readable text stays available to assistive technology while decorative glyphs are hidden. Reduced motion swaps the label in place and clears outgoing glyphs. Use animateWidth={false} for wrapping text.",
      "previewNote": "Demo values are local; no network requests.",
      "documentation": {
        "useCases": [
          {
            "title": "Button labels",
            "description": "Move between Save, Saving and Saved in the same button."
          },
          {
            "title": "Hero headlines",
            "description": "Change one word while keeping the rest of a sentence anchored."
          },
          {
            "title": "Status labels",
            "description": "Let a badge explain a change without a sudden text swap."
          },
          {
            "title": "Plan names",
            "description": "Update a selected plan or billing label in place."
          }
        ],
        "features": [
          {
            "title": "Shared letters keep their place",
            "description": "Matching characters stay mounted and move to their next position."
          },
          {
            "title": "A soft entrance and exit",
            "description": "New letters arrive with a short stagger; outgoing characters lift and fade."
          },
          {
            "title": "The width follows the words",
            "description": "The surrounding inline box resizes as the label changes."
          },
          {
            "title": "Readable without motion",
            "description": "A real text layer remains available, with a static reduced-motion presentation."
          }
        ],
        "states": [
          {
            "title": "Shared letters",
            "description": "Matching glyphs move between their previous and new positions."
          },
          {
            "title": "New and leaving letters",
            "description": "New characters enter; removed characters blur and fade away."
          },
          {
            "title": "Unrelated labels",
            "description": "Labels with little overlap transition as whole groups."
          },
          {
            "title": "Reduced motion",
            "description": "Text updates immediately without spatial animation."
          }
        ],
        "props": [
          {
            "name": "children",
            "type": "string",
            "description": "The current label. Change it to start a morph."
          },
          {
            "name": "duration",
            "type": "number · 460",
            "description": "Duration in milliseconds."
          },
          {
            "name": "direction",
            "type": "\"up\" | \"down\"",
            "description": "The direction new characters enter from. Defaults to up."
          },
          {
            "name": "animateWidth",
            "type": "boolean · true",
            "description": "Animate the inline width; disable for multi-line text."
          }
        ]
      },
      "categories": [
        "text"
      ]
    }
  },
  "type": "registry:component"
}