{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/text-morph/text-morph.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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\";\nimport styles from \"./text-morph.module.css\";\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 { children: string; as?: \"span\" | \"div\" | \"p\" | \"strong\" | \"h1\" | \"h2\" | \"h3\"; className?: string; id?: string }\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 = .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 => { const count = seen.get(char) ?? 0; seen.set(char, count + 1); return { char, key: `${char}-${count}` }; });\n}\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, 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) > .5 && !reduced) sizing.current = animate(frameElement, { width: next }, motionTokens.spring.morph);\n    else { sizing.current?.stop(); frameElement.style.width = `${next}px`; }\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, 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) < .5) return;\n      sizing.current?.stop(); frameElement.style.width = `${next}px`; width.current = next;\n    });\n    observer.observe(trackElement);\n    return () => observer.disconnect();\n  }, []);\n\n  return <Tag id={id} className={className}>\n    <span className={styles.srOnly}>{children}</span>\n    <span ref={frame} className={styles.frame} aria-hidden=\"true\"><span ref={track} className={styles.track}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {glyphs.map(({ char, key }) => { const order = kept.has(key) ? 0 : entering++; return <motion.span key={key} layout=\"position\" className={styles.glyph}\n          initial={reduced ? false : { opacity: 0, scale: .8, y: \"0.14em\", filter: blurred }}\n          animate={{ opacity: 1, scale: 1, y: \"0em\", filter: \"blur(0px)\" }}\n          exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .86, y: \"-0.1em\", filter: blurred, transition: { duration: motionTokens.duration.exit, ease: standard } }}\n          transition={reduced ? { duration: 0 } : { layout: motionTokens.spring.morph, default: { duration: motionTokens.duration.standard + .04, ease: enter, delay: HANDOFF + Math.min(order, 8) * motionTokens.stagger.char * 2 } }}>\n          {char === \" \" ? \"\\u00a0\" : char}\n        </motion.span>; })}\n      </AnimatePresence>\n    </span></span>\n  </Tag>;\n}\n\nexport default TextMorph;\n"
    },
    {
      "path": "registry/components/text-morph/text-morph.module.css",
      "type": "registry:component",
      "target": "@components/xzero/text-morph/text-morph.module.css",
      "content": "/* 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. */\n.frame.frame, .frame { position: relative; display: inline-block; box-sizing: content-box; padding: .5em .5em .5em .2em; margin: -.5em -.5em -.5em -.2em; text-align: start; white-space: nowrap; mask-image: linear-gradient(90deg, #000 calc(100% - .5em), transparent); }\n/* The track sizes to the letters in flow. Leaving letters are taken out of flow and positioned against it. */\n.track { position: relative; display: inline-block; white-space: nowrap; }\n.glyph { display: inline-block; white-space: pre; transform-origin: 50% 60%; }\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; user-select: none; }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/text-morph",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "text",
      "motion",
      "status"
    ],
    "docs": "https://ui.xzero.co.uk/components/text-morph",
    "markdown": "https://ui.xzero.co.uk/components/text-morph/markdown"
  }
}