{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-morph",
  "type": "registry:ui",
  "title": "Text Morph",
  "description": "Shared letters slide into their new places while the width springs.",
  "categories": [
    "content"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/text-morph.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/**\n * Stable keys per character: the nth \"e\" in one string is the nth \"e\" in the\n * next, so shared letters are the same element and glide to their new place.\n */\nexport function morphKeys(text: string) {\n  const seen = new Map<string, number>();\n  return Array.from(text).map((char) => {\n    const n = seen.get(char) ?? 0;\n    seen.set(char, n + 1);\n    return { char, key: `${char === \" \" ? \"space\" : char}-${n}` };\n  });\n}\n\nexport type TextMorphProps = Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> & {\n  /** The current text. Change it and shared characters move into place. Single line. */\n  children: string;\n  /** Animate the width too, so a button or pill around it resizes smoothly instead of snapping. */\n  animateWidth?: boolean;\n  ref?: React.Ref<HTMLSpanElement>;\n};\n\n/**\n * Morphs between two strings: letters both share slide to where they sit in\n * the new word, the rest blur out and in. For labels that change in place,\n * \"Deploy\" to \"Deploying\", \"Draft\" to \"Published\", \"Follow\" to \"Following\".\n */\nexport function TextMorph({ children, animateWidth = true, className, style, ref, ...rest }: TextMorphProps) {\n  const reduce = useReducedMotion();\n  const self = useRef<HTMLSpanElement>(null);\n  const inner = useRef<HTMLSpanElement>(null);\n  // \"auto\" until measured, so the server render and first paint are the plain width.\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const chars = morphKeys(children);\n\n  // Follow the natural width of the string: the first measurement is applied as\n  // is, every change after it springs, so the button around it resizes smoothly.\n  useEffect(() => {\n    const el = inner.current;\n    if (!el || !animateWidth) return;\n    let running: ReturnType<typeof animate> | undefined;\n    const ro = new ResizeObserver(() => {\n      const next = el.offsetWidth;\n      const now = width.get();\n      if (now === \"auto\" || reduce) return width.set(next);\n      if (now === next) return;\n      running?.stop();\n      // While the width is moving, the trailing edge feathers so letters are\n      // unveiled (or tucked away) softly instead of drawing over the padding.\n      self.current?.setAttribute(\"data-resizing\", \"\");\n      const current = animate(width, next, spring.soft);\n      running = current;\n      current.then(() => {\n        if (running === current) self.current?.removeAttribute(\"data-resizing\");\n      });\n    });\n    ro.observe(el);\n    return () => {\n      ro.disconnect();\n      running?.stop();\n      width.set(\"auto\");\n    };\n  }, [animateWidth, reduce, width]);\n\n  const move = reduce ? { duration: 0 } : spring.soft;\n\n  return (\n    <motion.span\n      ref={(node: HTMLSpanElement | null) => {\n        self.current = node;\n        if (typeof ref === \"function\") return ref(node);\n        if (ref) (ref as React.RefObject<HTMLSpanElement | null>).current = node;\n      }}\n      data-text-morph=\"\"\n      style={{ ...style, width }}\n      className={cn(\n        \"relative inline-block whitespace-nowrap align-bottom\",\n        \"data-resizing:mask-r-from-[calc(100%-0.6em)] data-resizing:mask-r-to-100%\",\n        className,\n      )}\n      {...(rest as React.ComponentProps<typeof motion.span>)}\n    >\n      <span className=\"sr-only\">{children}</span>\n      <span ref={inner} aria-hidden className=\"relative inline-flex w-max\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {chars.map(({ char, key }) => (\n            <motion.span\n              key={key}\n              layout={reduce ? false : \"position\"}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, filter: \"blur(3px)\", scale: 0.85 }}\n              animate={{ opacity: 1, filter: \"blur(0px)\", scale: 1 }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, filter: \"blur(3px)\", scale: 0.85, transition: { duration: 0.14, ease: ease.in } }}\n              transition={{\n                layout: move,\n                // New letters arrive a beat after the shared ones start moving, so the eye follows the move first.\n                default: reduce ? { duration: 0.15 } : { duration: 0.26, ease: ease.out, delay: 0.06 },\n              }}\n              className=\"inline-block whitespace-pre\"\n            >\n              {char}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/text-morph"
}
