{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-morph",
  "title": "Text Morph",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Rótulo que vira a próxima palavra letra por letra, com as letras em comum deslizando e a largura indo junto numa mola.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/text-morph.tsx",
      "content": "\"use client\";\n\n/* Adaptado de Text Morph — https://uiarc.dev/components/text-morph — Copyright (c) 2026 Elia Kuratli, licença MIT (texto integral em ATTRIBUTIONS.md do @visant/ui). */\n\nimport * as React from \"react\";\nimport { AnimatePresence, animate, motion, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\n\nimport { DUR, EASE_OUT, INSTANT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Text Morph — a palavra que vira a próxima sem piscar.\n *\n * Pra rótulo de estado que muda poucas letras: \"Publicar → Publicando →\n * Publicado\", \"Seguir → Seguindo\". Trocar o texto seco faz o botão mudar de\n * largura num quadro e o olho perde onde estava. Aqui as letras que as duas\n * palavras têm em comum deslizam pro lugar novo, as que chegam nascem de um\n * desfoque curto, as que saem se desfazem, e a largura acompanha numa mola.\n *\n * ── O que não é óbvio ──────────────────────────────────────────────────────\n *\n * 1. **Letra casa por caractere E ocorrência.** O segundo \"i\" de uma palavra é\n *    o segundo \"i\" da outra. Sem isso, palavras com letra repetida embaralham.\n * 2. **Só as letras novas escalonam**, na ordem de leitura. As que ficam já\n *    estão na tela; atrasá-las seria fingir que chegaram agora.\n * 3. **A moldura carrega a largura explícita.** A trilha de dentro já tem as\n *    letras novas no fluxo; a moldura mede a trilha e vai até ela numa mola.\n *    Carregar fonte ou redimensionar segue a medida na hora, sem animar,\n *    porque não é mudança de conteúdo.\n * 4. **A máscara mora no respiro.** Padding com margem negativa dá espaço pro\n *    desfoque e pras descendentes sem mexer no layout, e a borda esmaecida\n *    fica toda nesse espaço: texto parado nunca aparece apagado.\n * 5. **Leitor de tela lê o texto puro**, num `sr-only`. As letras animadas são\n *    `aria-hidden`. Não anuncia troca: quem precisa disso embrulha num\n *    `aria-live` do lado de fora, onde se sabe se a troca importa.\n *\n * Nunca quebra linha. Mantenha o rótulo curto.\n */\n\nexport interface TextMorphProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** O rótulo atual. Trocar a string dispara o morph. */\n  children: string;\n  /** Elemento renderizado. */\n  as?: \"span\" | \"div\" | \"p\" | \"strong\" | \"h1\" | \"h2\" | \"h3\";\n}\n\nconst ENTRA = EASE_OUT;\nconst SAI = EASE_OUT;\nconst MOLA = { type: \"spring\", visualDuration: 0.42, bounce: 0.16 } as const;\n// As letras que saem começam a clarear antes da primeira nova ocupar o lugar.\nconst PASSAGEM = 0.05;\n\nfunction letras(texto: string) {\n  const vistos = new Map<string, number>();\n  return Array.from(texto).map((char) => {\n    const n = vistos.get(char) ?? 0;\n    vistos.set(char, n + 1);\n    return { char, key: `${char}-${n}` };\n  });\n}\n\nconst largura = (el: HTMLElement) => parseFloat(getComputedStyle(el).width);\n\nexport function TextMorph({ children, as: Tag = \"span\", className, ...props }: TextMorphProps) {\n  const reduzir = !!useReducedMotion();\n  const moldura = React.useRef<HTMLSpanElement>(null);\n  const trilha = React.useRef<HTMLSpanElement>(null);\n  const atual = React.useRef(0);\n  const mola = React.useRef<AnimationPlaybackControls | null>(null);\n  const glifos = React.useMemo(() => letras(children), [children]);\n\n  const [rotulos, setRotulos] = React.useState({ agora: children, antes: children });\n  if (rotulos.agora !== children) setRotulos({ agora: children, antes: rotulos.agora });\n  const mantidas = React.useMemo(() => new Set(letras(rotulos.antes).map((g) => g.key)), [rotulos.antes]);\n  let chegando = 0;\n\n  React.useLayoutEffect(() => {\n    const m = moldura.current;\n    const t = trilha.current;\n    if (!m || !t) return;\n    const proxima = largura(t);\n    if (!Number.isFinite(proxima)) return;\n    if (atual.current && Math.abs(proxima - atual.current) > 0.5 && !reduzir) {\n      mola.current = animate(m, { width: proxima }, MOLA);\n    } else {\n      mola.current?.stop();\n      m.style.width = `${proxima}px`;\n    }\n    atual.current = proxima;\n  }, [children, reduzir]);\n\n  React.useEffect(() => {\n    const m = moldura.current;\n    const t = trilha.current;\n    if (!m || !t || typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(() => {\n      const proxima = largura(t);\n      if (!Number.isFinite(proxima) || Math.abs(proxima - atual.current) < 0.5) return;\n      mola.current?.stop();\n      m.style.width = `${proxima}px`;\n      atual.current = proxima;\n    });\n    ro.observe(t);\n    return () => ro.disconnect();\n  }, []);\n\n  return (\n    <Tag className={className} {...props}>\n      <span className=\"sr-only\">{children}</span>\n      <span\n        ref={moldura}\n        aria-hidden=\"true\"\n        style={{ padding: \"0.5em 0.5em 0.5em 0.2em\", margin: \"-0.5em -0.5em -0.5em -0.2em\" }}\n        className=\"relative box-content inline-block whitespace-nowrap text-start [mask-image:linear-gradient(90deg,black_calc(100%-0.5em),transparent)]\"\n      >\n        <span ref={trilha} className=\"relative inline-block whitespace-nowrap\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {glifos.map(({ char, key }) => {\n              const ordem = mantidas.has(key) ? 0 : chegando++;\n              return (\n                <motion.span\n                  key={key}\n                  layout=\"position\"\n                  className=\"inline-block origin-[50%_60%] whitespace-pre\"\n                  initial={reduzir ? false : { opacity: 0, scale: 0.8, y: \"0.14em\", filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, scale: 1, y: \"0em\", filter: \"blur(0px)\" }}\n                  exit={\n                    reduzir\n                      ? { opacity: 0, transition: INSTANT }\n                      : { opacity: 0, scale: 0.86, y: \"-0.1em\", filter: \"blur(4px)\", transition: { duration: DUR.fast, ease: SAI } }\n                  }\n                  transition={\n                    reduzir\n                      ? INSTANT\n                      : { layout: MOLA, default: { duration: DUR.slow, ease: ENTRA, delay: PASSAGEM + Math.min(ordem, 8) * 0.032 } }\n                  }\n                >\n                  {char === \" \" ? \" \" : char}\n                </motion.span>\n              );\n            })}\n          </AnimatePresence>\n        </span>\n      </span>\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Rótulo de botão ou status que vira outra palavra letra por letra, como Salvar pra Salvo",
      "evite": "Número mudando: slot-text; título de seção entrando, line-reveal ou mist-text"
    }
  },
  "categories": [
    "texto",
    "morph",
    "botao",
    "estado",
    "micro-interacao"
  ],
  "type": "registry:ui"
}