{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "count-up",
  "title": "Count Up",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "O número que se apura em vez de aparecer pronto: lê o valor final do próprio texto, trava a largura antes do primeiro quadro e não anima o que não dá pra parsear.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/count-up.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Count Up — o número que se apura em vez de aparecer pronto.\n *\n * Numa dobra de prova, o único elemento que AFIRMA um valor costuma ser o único\n * que não mostra o valor sendo apurado. A contagem é o gesto que falta. E ela é\n * de marketing (uma vez por sessão, na rolagem), não de controle respondendo a\n * clique — por isso pode passar dos 300 ms sem contrariar o motion da casa.\n *\n * ── O contrato, na ordem das armadilhas que ele evita ──────────────────────\n *\n * 1. **O texto final é a fonte.** O componente recebe a string pronta como\n *    `children` (`\"R$450k\"`, `\"1.200\"`, `\"83%\"`), extrai o número dela e\n *    reescreve exatamente a mesma string. Não existe `data-to` duplicando a\n *    copy: um número e a sua formatação são a mesma decisão editorial, e\n *    separá-los cria duas fontes pra mesma frase.\n * 2. **O que não dá pra parsear simplesmente não anima.** `\"—\"`, `\"Em breve\"`,\n *    `\"0\"` passam intactos. `null` aqui significa \"deixa quieto\", nunca\n *    \"esvazia\" — um contador que zera um traço é pior que contador nenhum.\n * 3. **`prefers-reduced-motion` sai fora inteiro.** Contagem é movimento puro,\n *    e o dado continua lá.\n * 4. **A largura é travada antes da primeira troca de texto.** Sem isso,\n *    `0 → 450` empurra o rótulo e a célula vizinha a cada quadro. A trava é\n *    medida com o valor final ainda na tela: é a única janela em que a medida é\n *    a do número cheio.\n * 5. **Sem JS, sem observer, sem hidratação: o número já está no HTML.** O\n *    componente só pode TIRAR o valor de vista depois de garantir que devolve.\n *\n * O locale entra por prop porque agrupamento de milhar é decisão de mercado, e\n * o registry serve cliente em mais de um.\n */\n\ntype Partes = {\n  pre: string;\n  pos: string;\n  val: number;\n  dec: number;\n  agrupado: boolean;\n};\n\n/** `\"R$450k\"` → pre `\"R$\"`, 450, pos `\"k\"`. `\"1.200\"` → 1200 agrupado. */\nfunction analisar(texto: string, decimal: string, milhar: string): Partes | null {\n  const m = /^([^\\d]*)(\\d[\\d.,\\s]*?)([^\\d]*)$/.exec(texto.trim());\n  if (!m) return null;\n  const cru = m[2];\n  const iDec = cru.lastIndexOf(decimal);\n  const dec = iDec >= 0 ? cru.length - iDec - 1 : 0;\n  const normal = cru.split(milhar).join(\"\").replace(decimal, \".\");\n  const val = parseFloat(normal);\n  if (!isFinite(val) || val <= 0) return null;\n  return { pre: m[1], pos: m[3], val, dec, agrupado: cru.includes(milhar) };\n}\n\n// easeOutQuint — o parente em JS do `--ease-out` da casa: chega rápido e assenta.\nconst ease = (t: number) => 1 - Math.pow(1 - t, 5);\n\nexport interface CountUpProps extends React.HTMLAttributes<HTMLSpanElement> {\n  /** O valor FINAL, já formatado. É ele que a contagem devolve no último quadro. */\n  children: string;\n  /** ms. Default 900 — fecha logo depois de uma entrada de dobra de 0.7s. */\n  duration?: number;\n  /** Atraso, pra escalonar uma fileira de números. */\n  delay?: number;\n  locale?: string;\n  /** Separadores da string de ENTRADA. Default pt-BR. */\n  decimalSep?: string;\n  thousandSep?: string;\n}\n\nexport function CountUp({\n  children,\n  duration = 900,\n  delay = 0,\n  locale = \"pt-BR\",\n  decimalSep = \",\",\n  thousandSep = \".\",\n  className,\n  ...props\n}: CountUpProps) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const partes = React.useMemo(\n    () => analisar(children, decimalSep, thousandSep),\n    [children, decimalSep, thousandSep],\n  );\n\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el || !partes) return;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    if (!(\"IntersectionObserver\" in window)) return;\n\n    const formatar = (n: number) => {\n      const corpo = partes.agrupado\n        ? n.toLocaleString(locale, {\n            minimumFractionDigits: partes.dec,\n            maximumFractionDigits: partes.dec,\n          })\n        : n.toFixed(partes.dec);\n      return partes.pre + corpo + partes.pos;\n    };\n\n    /*\n     * Trava a largura AGORA, com o valor final ainda desenhado — mas a largura\n     * do TEXTO, não a da caixa.\n     *\n     * `getBoundingClientRect()` do elemento responde a largura do bloco, e num\n     * `display: block` isso é a coluna inteira. O número \"1.291\" numa coluna de\n     * 443px gravava `min-width: 443px` no estilo inline: a partir daí a peça\n     * tinha um piso de largura que não encolhia nunca. Numa página que muda de\n     * largura, o resultado é a coluna empurrando a página de lado no celular —\n     * defeito que só aparece DEPOIS de o layout mudar, então passa por todo\n     * teste que carrega a página já no tamanho final.\n     *\n     * Um `Range` sobre o conteúdo mede a tinta, que é o que a reserva quer:\n     * impedir o salto enquanto os dígitos trocam. E `max-width: 100%` fecha a\n     * porta de vez — reserva nenhuma pode valer mais que o espaço que existe.\n     */\n    const range = document.createRange();\n    range.selectNodeContents(el);\n    const tinta = range.getBoundingClientRect().width;\n    range.detach?.();\n    // O retângulo vem na escala da tela e o `minWidth` vale em px da peça: sem dividir,\n    // num palco escalado a trava saía maior que o número e empurrava os vizinhos.\n    const caixa = el.getBoundingClientRect().width;\n    const escala = caixa && el.offsetWidth ? caixa / el.offsetWidth : 1;\n    el.style.minWidth = `${Math.ceil((tinta || caixa) / escala)}px`;\n    el.style.maxWidth = \"100%\";\n\n    let raf = 0;\n    let timer = 0;\n    const io = new IntersectionObserver(\n      (es) => {\n        if (!es[0].isIntersecting) return;\n        io.disconnect();\n        let inicio: number | null = null;\n        const passo = (now: number) => {\n          if (inicio === null) inicio = now;\n          const t = Math.min(1, (now - inicio) / duration);\n          el.textContent = formatar(t === 1 ? partes.val : partes.val * ease(t));\n          if (t < 1) raf = requestAnimationFrame(passo);\n        };\n        timer = window.setTimeout(() => {\n          raf = requestAnimationFrame(passo);\n        }, delay);\n      },\n      { threshold: 0.6 },\n    );\n\n    el.textContent = formatar(0); // só depois do observer existir\n    io.observe(el);\n\n    return () => {\n      io.disconnect();\n      cancelAnimationFrame(raf);\n      clearTimeout(timer);\n      el.textContent = children; // desmontar nunca deixa um zero na tela\n    };\n  }, [partes, duration, delay, locale, children]);\n\n  return (\n    <span ref={ref} className={cn(\"tabular-nums\", className)} {...props}>\n      {children}\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Estatística de landing que apura até o valor ao entrar na tela, como 120 projetos entregues",
      "evite": "Número que muda ao vivo: slot-text; contagem até data, countdown; número com régua, measure-bar"
    }
  },
  "categories": [
    "conteudo",
    "numero",
    "contagem",
    "prova",
    "estatistica",
    "animar"
  ],
  "type": "registry:ui"
}