{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-morph","type":"registry:component","title":"Text morph","description":"Text that changes the way it should: shared letters slide over, new ones rise out of a blur, the width eases.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/text-morph.tsx","type":"registry:component","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<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\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(previous: Glyph[], text: string, nextKey: () => number): 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({ length: a.length + 1 }, () => new Uint16Array(b.length + 1));\n  for (let i = a.length - 1; i >= 0; i--)\n    for (let j = b.length - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : 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({ glyph, lift, duration, onDone }: { glyph: Leaving; lift: number; duration: number; onDone: (key: number) => void }) {\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 ref={ref} aria-hidden=\"true\" className=\"pointer-events-none absolute whitespace-pre\" style={{ left: glyph.x, top: glyph.y }}>\n      {glyph.char}\n    </span>\n  );\n}\n\nexport function TextMorph({ children: text, duration = 460, direction = \"up\", animateWidth = true, className = \"\", ...props }: 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[]>(() => Array.from(text).map((char, i) => ({ key: i + 1, char })));\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<{ at: Map<number, { x: number; y: number }>; width: number } | null>(null);\n  const rise = direction === \"up\" ? 0.35 : -0.35;\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) => [...l, ...glyphs.filter((g) => !kept.has(g.key)).map((g) => ({ ...g, ...(at.get(g.key) ?? { x: 0, y: 0 }) }))]);\n    setGlyphs(next);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\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([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: \"none\" }], { duration, easing: EASE });\n      } else {\n        el.animate(\n          [\n            { opacity: 0, filter: \"blur(4px)\", transform: `translateY(${rise}em)` },\n            { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n          ],\n          { duration, easing: EASE, delay: Math.min(order++ * 16, 160), fill: \"backwards\" },\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([{ width: `${prev.width}px` }, { width: `${box.width}px` }], { duration, easing: EASE });\n      resize.onfinish = resize.oncancel = () => {\n        root.style.whiteSpace = \"\";\n      };\n    }\n  }, [glyphs, duration, rise, animateWidth]);\n\n  const done = React.useCallback((key: number) => setLeaving((l) => l.filter((g) => g.key !== key)), []);\n\n  // Words stay unbroken; lines may only wrap at spaces.\n  const words: Glyph[][] = [];\n  for (const g of glyphs) {\n    if (g.char === \" \" || !words.length || words[words.length - 1][0]?.char === \" \") words.push([g]);\n    else words[words.length - 1].push(g);\n  }\n\n  return (\n    <span ref={rootRef} className={`relative inline-block ${className}`} {...props}>\n      <span className=\"sr-only\">{text}</span>\n      {words.map((word, w) => (\n        <span key={w} aria-hidden=\"true\" className=\"inline-block whitespace-pre\">\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 key={`out-${g.key}`} glyph={g} lift={-rise} duration={duration} onDone={done} />\n      ))}\n    </span>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/text-morph"}