{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "billing-toggle",
  "title": "Billing Toggle",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Interruptor mensal e anual com o selo de desconto que vira recibo quando o anual é escolhido, e o preço rolando junto.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/billing-toggle.tsx",
      "content": "\"use client\";\n\n/*\n * Adaptado de Billing Toggle — https://uiarc.dev/components/billing-toggle — Copyright (c) 2026 Elia Kuratli, licença MIT (texto integral em ATTRIBUTIONS.md do @visant/ui).\n */\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, type Variants } from \"motion/react\";\n\nimport { DUR, EASE_OUT, INSTANT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Billing Toggle, o mensal/anual que vende o anual sem gritar.\n *\n * Na tabela de preço, o interruptor de período é o único lugar onde o desconto\n * do anual aparece NA HORA da decisão. Por isso o selo mora dentro da opção, e\n * muda de frase quando ela é escolhida: antes promete (\"-20%\"), depois confirma\n * (\"Você economiza R$ 96\"). A promessa vira recibo no mesmo lugar.\n *\n * Decisões que não se veem de primeira:\n *\n * 1. **Um polegar só, que anda.** É o movimento que conta de onde se veio. Ele\n *    é medido (posição e largura do botão escolhido), porque as opções têm\n *    larguras diferentes: a do selo é maior, e colunas iguais desperdiçariam a\n *    mensal.\n * 2. **O selo cresce na mola até a frase nova.** Travar a caixa na frase mais\n *    longa deixava o \"-20%\" boiando numa barra vazia, que lia como progresso.\n *    Agora o selo tem o tamanho do que diz, a largura viaja com a mesma mola\n *    do polegar, e o polegar, que mede o botão, acompanha.\n * 3. **O selo é fundo da marca, nunca texto da marca.** `var(--brand)` como cor\n *    de letra no branco dá 1,29:1. Escolhido, o selo vira chapa da marca com\n *    `--brand-foreground` por cima; fora disso, fica neutro.\n * 4. **É um radiogroup de verdade.** Tab entra só na opção escolhida, setas\n *    trocam e levam o foco, Home/End vão às pontas.\n *\n * `BillingPrice` vem junto porque o toggle sozinho não fecha a conta: o número\n * precisa rolar pro valor novo no mesmo gesto, subindo quando sobe e descendo\n * quando desce. Guarde o período num estado só e derive todos os preços dele.\n */\n\nexport interface BillingToggleOption {\n  value: string;\n  label: string;\n  /** Selo curto de desconto, como \"-20%\". */\n  badge?: string;\n  /** Frase do selo depois de escolhida, como \"Você economiza R$ 96\". Cai no `badge` se faltar. */\n  activeBadge?: string;\n}\n\nexport interface BillingToggleProps {\n  /** Período escolhido. Controlado: guarde no estado do pai. */\n  value: string;\n  onValueChange: (value: string) => void;\n  options?: BillingToggleOption[];\n  /** Nome acessível do grupo. */\n  label?: string;\n  className?: string;\n}\n\nconst OPCOES: BillingToggleOption[] = [\n  { value: \"mensal\", label: \"Mensal\" },\n  { value: \"anual\", label: \"Anual\", badge: \"-20%\", activeBadge: \"2 meses grátis\" },\n];\n\n/** Criticamente amortecida: o polegar desliza e pousa sem quicar. */\nconst DESLIZE = { type: \"spring\" as const, visualDuration: 0.28, bounce: 0 };\n\n/** O rótulo que sai vai seco, sem disputar a atenção com o que entra. */\nconst SAIDA_SECA = [0.4, 0, 1, 1] as const;\n\nconst troca: Variants = {\n  antes: { opacity: 0, y: \"0.5em\" },\n  agora: { opacity: 1, y: 0, transition: { duration: DUR.base, ease: EASE_OUT } },\n  depois: { opacity: 0, y: \"-0.5em\", transition: { duration: DUR.press, ease: SAIDA_SECA } },\n};\nconst quieto: Variants = {\n  antes: { opacity: 0 },\n  agora: { opacity: 1, transition: { duration: DUR.press } },\n  depois: { opacity: 0, transition: INSTANT },\n};\n\n/** O selo dá um pulinho quando a opção dele é escolhida. Rótulo de variante só dispara na troca. */\nconst pulo: Variants = {\n  on: { scale: [1, 1.08, 1], transition: { duration: DUR.slow, ease: EASE_OUT } },\n  off: { scale: 1 },\n};\n\nconst rola: Variants = {\n  antes: (d: number) => ({ opacity: 0, y: `${d * 60}%` }),\n  agora: { opacity: 1, y: 0 },\n  depois: (d: number) => ({ opacity: 0, y: `${d * -60}%` }),\n};\n\n/**\n * Texto que troca no lugar, com a caixa indo na mola até a largura da frase nova.\n * Um medidor invisível dá a largura; a frase de saída fica presa na caixa e é\n * cortada por ela, então nada empurra o vizinho de supetão.\n */\nfunction TrocaFixa({ texto, reduzido }: { texto: string; reduzido: boolean }) {\n  const medidor = React.useRef<HTMLSpanElement>(null);\n  const [largura, setLargura] = React.useState<number | null>(null);\n\n  React.useLayoutEffect(() => {\n    const el = medidor.current;\n    if (!el) return;\n    const medir = () => setLargura(el.offsetWidth);\n    medir();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const obs = new ResizeObserver(medir);\n    obs.observe(el);\n    return () => obs.disconnect();\n  }, [texto]);\n\n  return (\n    <motion.span\n      className=\"relative inline-grid overflow-hidden [&>*]:[grid-area:1/1]\"\n      initial={false}\n      animate={largura === null ? undefined : { width: largura }}\n      transition={reduzido ? INSTANT : DESLIZE}\n    >\n      <span ref={medidor} aria-hidden=\"true\" className=\"invisible absolute left-0 top-0 whitespace-nowrap\">\n        {texto}\n      </span>\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={texto}\n          className=\"justify-self-center whitespace-nowrap\"\n          variants={reduzido ? quieto : troca}\n          initial=\"antes\"\n          animate=\"agora\"\n          exit=\"depois\"\n        >\n          {texto}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\nexport function BillingToggle({\n  value,\n  onValueChange,\n  options = OPCOES,\n  label = \"Período de cobrança\",\n  className,\n}: BillingToggleProps) {\n  const reduzido = !!useReducedMotion();\n  const raizRef = React.useRef<HTMLDivElement>(null);\n  const botoes = React.useRef<Array<HTMLButtonElement | null>>([]);\n  const [polegar, setPolegar] = React.useState<{ x: number; w: number } | null>(null);\n  const escolhido = Math.max(0, options.findIndex((o) => o.value === value));\n\n  const medir = React.useCallback(() => {\n    const no = botoes.current[escolhido];\n    if (!no) return;\n    setPolegar((p) => (p && p.x === no.offsetLeft && p.w === no.offsetWidth ? p : { x: no.offsetLeft, w: no.offsetWidth }));\n  }, [escolhido]);\n\n  // Mede de novo quando a raiz muda de tamanho: fonte carregando tarde também muda a largura do botão.\n  React.useLayoutEffect(() => {\n    medir();\n    const raiz = raizRef.current;\n    if (!raiz || typeof ResizeObserver === \"undefined\") return;\n    const obs = new ResizeObserver(medir);\n    obs.observe(raiz);\n    return () => obs.disconnect();\n  }, [medir]);\n\n  const aoTeclar = (e: React.KeyboardEvent<HTMLButtonElement>, i: number) => {\n    const n = options.length;\n    const passo = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? -1 : 0;\n    const alvo = e.key === \"Home\" ? 0 : e.key === \"End\" ? n - 1 : passo ? (i + passo + n) % n : -1;\n    if (alvo < 0) return;\n    e.preventDefault();\n    onValueChange(options[alvo]!.value);\n    botoes.current[alvo]?.focus();\n  };\n\n  return (\n    <div\n      ref={raizRef}\n      role=\"radiogroup\"\n      aria-label={label}\n      className={cn(\n        \"relative inline-flex items-center rounded-[var(--r-pill)] border border-border bg-muted p-1 text-sm\",\n        className,\n      )}\n    >\n      {polegar && (\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"absolute inset-y-1 left-0 rounded-[var(--r-pill)] bg-background [box-shadow:var(--e-raised)]\"\n          initial={false}\n          animate={{ x: polegar.x, width: polegar.w }}\n          transition={reduzido ? INSTANT : DESLIZE}\n        />\n      )}\n      {options.map((o, i) => {\n        const ativo = i === escolhido;\n        const selo = ativo ? (o.activeBadge ?? o.badge) : o.badge;\n        return (\n          <button\n            key={o.value}\n            ref={(no) => {\n              botoes.current[i] = no;\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={ativo}\n            tabIndex={ativo ? 0 : -1}\n            onClick={() => onValueChange(o.value)}\n            onKeyDown={(e) => aoTeclar(e, i)}\n            className={cn(\n              \"relative z-[1] inline-flex h-9 items-center gap-2 rounded-[var(--r-pill)] px-4 font-medium outline-none pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1\",\n              \"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-out)] focus-visible:[box-shadow:var(--focus)]\",\n              ativo ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n            )}\n          >\n            {/* Antes da primeira medida, o fundo mora no próprio botão: sem piscar vazio no primeiro quadro. */}\n            {ativo && !polegar && (\n              <span aria-hidden=\"true\" className=\"absolute inset-0 -z-[1] rounded-[inherit] bg-background [box-shadow:var(--e-raised)]\" />\n            )}\n            <span>{o.label}</span>\n            {selo && (\n              <motion.span\n                initial={false}\n                variants={reduzido ? undefined : pulo}\n                animate={ativo ? \"on\" : \"off\"}\n                className={cn(\n                  \"inline-flex h-5 items-center rounded-[var(--r-pill)] px-2 text-xs font-medium tabular-nums\",\n                  \"transition-colors duration-[var(--dur-base)] ease-[var(--ease-out)]\",\n                  ativo\n                    ? \"bg-[var(--brand)] text-[var(--brand-foreground)]\"\n                    : \"border border-border bg-background text-muted-foreground\",\n                )}\n              >\n                <TrocaFixa texto={selo} reduzido={reduzido} />\n              </motion.span>\n            )}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nexport interface BillingPriceProps {\n  /** O preço a mostrar. Mudou, rola. */\n  amount: number;\n  /** Prefixo da moeda. */\n  currency?: string;\n  /** Texto depois do preço, como \"/mês\". */\n  period?: string;\n  /** Preço antigo, riscado só quando é maior que `amount`. */\n  was?: number;\n  /** Locale da formatação: agrupamento de milhar é decisão de mercado. */\n  locale?: string;\n  className?: string;\n}\n\n/** O preço que rola pro valor novo: sobe quando aumenta, desce quando diminui. */\nexport function BillingPrice({ amount, currency = \"R$\", period, was, locale = \"pt-BR\", className }: BillingPriceProps) {\n  const reduzido = !!useReducedMotion();\n  const anterior = React.useRef(amount);\n  const direcao = amount >= anterior.current ? 1 : -1;\n  React.useEffect(() => {\n    anterior.current = amount;\n  }, [amount]);\n  const fmt = (v: number) => v.toLocaleString(locale);\n  const mostraAntigo = was !== undefined && was > amount;\n\n  return (\n    <span className={cn(\"inline-flex items-baseline gap-2 text-foreground\", className)}>\n      <span className=\"inline-flex items-baseline gap-1\">\n        <span className=\"text-base text-muted-foreground\">{currency}</span>\n        <span className=\"relative inline-grid overflow-hidden text-4xl font-semibold leading-none tabular-nums\">\n          <AnimatePresence initial={false} custom={direcao}>\n            <motion.span\n              key={amount}\n              custom={direcao}\n              className=\"[grid-area:1/1]\"\n              variants={reduzido ? quieto : rola}\n              initial=\"antes\"\n              animate=\"agora\"\n              exit=\"depois\"\n              transition={reduzido ? { duration: DUR.press } : { type: \"spring\", visualDuration: 0.3, bounce: 0.15 }}\n            >\n              {fmt(amount)}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </span>\n      {mostraAntigo && (\n        <del className=\"text-sm tabular-nums text-muted-foreground\">\n          {currency} {fmt(was)}\n        </del>\n      )}\n      {period && <span className=\"text-sm text-muted-foreground\">{period}</span>}\n    </span>\n  );\n}\n\nexport default BillingToggle;\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Alternar mensal e anual numa página de preços, com selo de desconto e preço rolando junto",
      "evite": "Alternar opções que não são cobrança: segmented-control"
    }
  },
  "categories": [
    "controles",
    "preco",
    "pricing",
    "toggle",
    "assinatura",
    "desconto",
    "radiogroup"
  ],
  "type": "registry:ui"
}