{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-morph",
  "type": "registry:ui",
  "title": "Text morph",
  "description": "Morph a label into its next state, letter by letter.",
  "categories": [
    "text"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/text-morph/text-morph.tsx",
      "content": "\"use client\";\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { AnimatePresence, animate, motion, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\n\n/**\n * Morphs one short label into the next in place. Letters both strings share glide to their new positions, new letters sharpen in, removed letters blur away,\n * and the width follows on a spring so the surrounding layout glides instead of jumping. Use it for status words that change a few letters at a time,\n * such as Publish, Publishing, and Published, or Follow and Following. It stays on one line, and assistive technology reads the plain text.\n */\nexport interface TextMorphProps {\n  children: string;\n  as?: \"span\" | \"div\" | \"p\" | \"strong\" | \"h1\" | \"h2\" | \"h3\";\n  className?: string;\n  id?: string;\n}\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst blurred = `blur(${motionTokens.blur.soft}px)`;\n/** Lets leaving letters start to clear before the first new letter sharpens in their place. */\nconst HANDOFF = 0.05;\n/** Letters match by character and occurrence, so the second \"i\" in one string pairs with the second \"i\" in the next. */\nfunction toGlyphs(text: string) {\n  const seen = new Map<string, number>();\n  return Array.from(text).map((char) => {\n    const count = seen.get(char) ?? 0;\n    seen.set(char, count + 1);\n    return { char, key: `${char}-${count}` };\n  });\n}\n/** The frame carries the animated width. Its mask reveals new letters through a soft trailing edge as it grows and fades leaving ones as it shrinks.\n *  Padding with matching negative margins gives the mask room for blur and descenders without changing layout; the edge sits entirely in that room, so resting text is never dimmed.\n *  The track always starts at the frame's leading edge, even in centered text: otherwise it would recenter inside the old width and leaving letters would jump. */\nconst frameClass =\n  \"relative inline-block box-content -mt-[.5em] -mr-[.5em] -mb-[.5em] -ml-[.2em] pt-[.5em] pr-[.5em] pb-[.5em] pl-[.2em] text-start whitespace-nowrap [mask-image:linear-gradient(90deg,#000_calc(100%_-_.5em),transparent)]\";\nconst measure = (element: HTMLElement) => parseFloat(getComputedStyle(element).width);\n\nexport function TextMorph({ children, as = \"span\", className, id }: TextMorphProps) {\n  const Tag = as;\n  const reduced = useReducedMotion();\n  const frame = useRef<HTMLSpanElement>(null);\n  const track = useRef<HTMLSpanElement>(null);\n  const width = useRef(0);\n  const sizing = useRef<AnimationPlaybackControls | null>(null);\n  const glyphs = useMemo(() => toGlyphs(children), [children]);\n  // The previous label tells which letters are new, so only they stagger, in reading order, however far along the word they sit.\n  const [labels, setLabels] = useState({ current: children, previous: children });\n  if (labels.current !== children) setLabels({ current: children, previous: labels.current });\n  const kept = useMemo(() => new Set(toGlyphs(labels.previous).map((glyph) => glyph.key)), [labels.previous]);\n  let entering = 0;\n\n  // The frame holds an explicit width so a new label never snaps it; the spring then follows the track, which already has the new letters in place.\n  useLayoutEffect(() => {\n    const frameElement = frame.current,\n      trackElement = track.current;\n    if (!frameElement || !trackElement) return;\n    const next = measure(trackElement);\n    if (!Number.isFinite(next)) return;\n    if (width.current && Math.abs(next - width.current) > 0.5 && !reduced)\n      sizing.current = animate(frameElement, { width: next }, motionTokens.spring.morph);\n    else {\n      sizing.current?.stop();\n      frameElement.style.width = `${next}px`;\n    }\n    width.current = next;\n  }, [children, reduced]);\n  // Font loading or a responsive font size changes the width without a new label: follow it immediately.\n  useEffect(() => {\n    const frameElement = frame.current,\n      trackElement = track.current;\n    if (!frameElement || !trackElement || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const next = measure(trackElement);\n      if (!Number.isFinite(next) || Math.abs(next - width.current) < 0.5) return;\n      sizing.current?.stop();\n      frameElement.style.width = `${next}px`;\n      width.current = next;\n    });\n    observer.observe(trackElement);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <Tag id={id} className={className}>\n      <span className=\"sr-only select-none\">{children}</span>\n      <span ref={frame} className={frameClass} aria-hidden=\"true\">\n        <span ref={track} className=\"relative inline-block whitespace-nowrap\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {glyphs.map(({ char, key }) => {\n              const order = kept.has(key) ? 0 : entering++;\n              return (\n                <motion.span\n                  key={key}\n                  layout=\"position\"\n                  className=\"inline-block origin-[50%_60%] whitespace-pre\"\n                  initial={reduced ? false : { opacity: 0, scale: 0.8, y: \"0.14em\", filter: blurred }}\n                  animate={{ opacity: 1, scale: 1, y: \"0em\", filter: \"blur(0px)\" }}\n                  exit={\n                    reduced\n                      ? { opacity: 0, transition: { duration: 0 } }\n                      : {\n                          opacity: 0,\n                          scale: 0.86,\n                          y: \"-0.1em\",\n                          filter: blurred,\n                          transition: { duration: motionTokens.duration.exit, ease: standard },\n                        }\n                  }\n                  transition={\n                    reduced\n                      ? { duration: 0 }\n                      : {\n                          layout: motionTokens.spring.morph,\n                          default: {\n                            duration: motionTokens.duration.standard + 0.04,\n                            ease: enter,\n                            delay: HANDOFF + Math.min(order, 8) * motionTokens.stagger.char * 2,\n                          },\n                        }\n                  }\n                >\n                  {char === \" \" ? \"\\u00a0\" : char}\n                </motion.span>\n              );\n            })}\n          </AnimatePresence>\n        </span>\n      </span>\n    </Tag>\n  );\n}\n\nexport default TextMorph;\n",
      "type": "registry:component",
      "target": "@components/iris/text-morph/text-morph.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/text-morph",
  "meta": {
    "kind": "component",
    "group": "Text & motion",
    "export": "TextMorph",
    "tags": [
      "text",
      "motion",
      "status"
    ],
    "docs": "https://irisui.dev/components/text-morph"
  }
}
