{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "magnetic-select",
  "title": "Magnetic Select",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Um cacho de opções redondas em que a escolhida incha e empurra as outras, e o cursor puxa a opção que está debaixo dele.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/magnetic-select.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nimport { INSTANT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Magnetic Select — um cacho de opções redondas onde a escolhida incha e as\n * outras abrem caminho; o cursor puxa a opção que está debaixo dele.\n *\n * Serve para escolha curta e visual (humor, cor, categoria, reação), em que um\n * `<select>` esconde demais e uma fileira de botões não diz \"uma só\". O cacho\n * diz: a escolhida ocupa o espaço de verdade, e as vizinhas se afastam dela.\n *\n * ── Decisões ───────────────────────────────────────────────────────────────\n *\n * 1. **O indicador é a própria opção.** Não há pílula correndo por trás: a\n *    escolhida cresce e veste a marca (`--brand` de fundo). Cresce por `scale`,\n *    e por isso o empurrão das vizinhas é calculado à parte — `scale` não mexe\n *    no layout de ninguém.\n * 2. **O empurrão é geometria, não layout.** Cada opção tem um lugar no cacho\n *    (centro + anel). Com a escolhida inchada, umas poucas passadas de\n *    relaxamento separam qualquer par que se sobreponha. A escolhida não sai do\n *    lugar: quem cede são as outras.\n * 3. **O ímã é só hover.** A opção sob o ponteiro anda uma fração da distância\n *    até ele (`pull`). Sem ponteiro fino (toque) não há ímã, há só o toque.\n * 4. **É um `radiogroup` de verdade.** Uma parada de Tab; setas trocam a\n *    escolha, Home/End vão às pontas. O rótulo da escolhida fica embaixo, numa\n *    região `aria-live`, porque um círculo com ícone não se explica sozinho.\n *\n * Com movimento reduzido tudo chega no lugar sem mola.\n */\n\nexport interface MagneticOption {\n  value: string;\n  label: string;\n  /** Ícone (lucide-react, por exemplo). Sem ícone, a inicial do rótulo. */\n  icon?: React.ReactNode;\n}\n\nexport interface MagneticSelectProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\"> {\n  options: MagneticOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Diâmetro das opções em repouso. */\n  size?: \"sm\" | \"lg\";\n  /** 0–100. Quanto a opção sob o cursor é puxada até ele. */\n  pull?: number;\n  /** 0–100. Balanço da mola ao assentar. */\n  bounce?: number;\n  /** 0–100. O quanto a escolhida incha (e empurra as outras). */\n  swell?: number;\n  /** Contorno nas opções em repouso, para fundos onde `bg-card` some. */\n  stroke?: boolean;\n  /** Nome do grupo para leitor de tela. */\n  label?: string;\n}\n\ntype Ponto = { x: number; y: number };\n\n/** Centro + anel. Com uma opção só, só o centro. */\nfunction lugares(n: number, passo: number): Ponto[] {\n  if (n <= 0) return [];\n  const out: Ponto[] = [{ x: 0, y: 0 }];\n  const anel = n - 1;\n  for (let i = 0; i < anel; i++) {\n    const a = -Math.PI / 2 + (i / anel) * Math.PI * 2 + Math.PI / anel;\n    out.push({ x: Math.cos(a) * passo, y: Math.sin(a) * passo });\n  }\n  return out;\n}\n\n/** Separa pares sobrepostos; a escolhida (`fixa`) nunca se move. */\nfunction relaxar(base: Ponto[], raios: number[], fixa: number, folga: number): Ponto[] {\n  const p = base.map((b) => ({ ...b }));\n  for (let it = 0; it < 12; it++) {\n    for (let i = 0; i < p.length; i++) {\n      for (let j = i + 1; j < p.length; j++) {\n        const dx = p[j].x - p[i].x;\n        const dy = p[j].y - p[i].y;\n        const d = Math.hypot(dx, dy) || 0.001;\n        const min = raios[i] + raios[j] + folga;\n        if (d >= min) continue;\n        const ux = dx / d;\n        const uy = dy / d;\n        const falta = min - d;\n        // Quem é fixo não cede; entre dois livres, cada um cede metade.\n        const wi = i === fixa ? 0 : j === fixa ? 1 : 0.5;\n        const wj = 1 - wi;\n        p[i].x -= ux * falta * wi;\n        p[i].y -= uy * falta * wi;\n        p[j].x += ux * falta * wj;\n        p[j].y += uy * falta * wj;\n      }\n    }\n  }\n  return p;\n}\n\nexport function MagneticSelect({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  size = \"lg\",\n  pull = 55,\n  bounce = 55,\n  swell = 50,\n  stroke = false,\n  label = \"Escolha uma opção\",\n  className,\n  ...props\n}: MagneticSelectProps) {\n  const reduzir = useReducedMotion() ?? false;\n  const [interno, setInterno] = React.useState(defaultValue ?? options[0]?.value);\n  const atual = value ?? interno;\n  const iAtual = Math.max(0, options.findIndex((o) => o.value === atual));\n\n  const [ima, setIma] = React.useState<{ i: number; dx: number; dy: number } | null>(null);\n  const botoes = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  const d = size === \"lg\" ? 48 : 36;\n  const r = d / 2;\n  const escala = 1.3 + (swell / 100) * 0.7;\n  const folga = d * 0.14;\n  const passo = d + folga;\n\n  const base = React.useMemo(() => lugares(options.length, passo), [options.length, passo]);\n  const raios = options.map((_, i) => (i === iAtual ? r * escala : r));\n  const pos = relaxar(base, raios, iAtual, folga);\n\n  // A caixa é o pior caso entre TODAS as escolhas possíveis, não a atual: se\n  // ela acompanhasse a escolha, trocar de opção empurraria a página.\n  const lado = React.useMemo(() => {\n    let meia = r;\n    for (let s = 0; s < base.length; s++) {\n      const rs = base.map((_, i) => (i === s ? r * escala : r));\n      relaxar(base, rs, s, folga).forEach((q, i) => {\n        meia = Math.max(meia, Math.abs(q.x) + rs[i], Math.abs(q.y) + rs[i]);\n      });\n    }\n    return Math.ceil(meia * 2 + d * 0.3);\n  }, [base, r, escala, folga, d]);\n\n  const mola = reduzir\n    ? INSTANT\n    : {\n        type: \"spring\" as const,\n        stiffness: 420,\n        damping: 2 * Math.sqrt(420) * (1 - (bounce / 100) * 0.65),\n      };\n\n  const escolher = (i: number, focar = false) => {\n    const o = options[i];\n    if (!o) return;\n    if (value === undefined) setInterno(o.value);\n    onValueChange?.(o.value);\n    if (focar) botoes.current[i]?.focus();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const n = options.length;\n    const mapa: Record<string, number> = {\n      ArrowRight: (iAtual + 1) % n,\n      ArrowDown: (iAtual + 1) % n,\n      ArrowLeft: (iAtual - 1 + n) % n,\n      ArrowUp: (iAtual - 1 + n) % n,\n      Home: 0,\n      End: n - 1,\n    };\n    if (!(e.key in mapa)) return;\n    // As setas são do grupo: sem parar aqui, um atalho de página (galeria,\n    // carrossel) ouvindo no `document` também anda e rouba o foco.\n    e.preventDefault();\n    e.stopPropagation();\n    escolher(mapa[e.key], true);\n  };\n\n  return (\n    <div className={cn(\"inline-flex flex-col items-center gap-3\", className)} {...props}>\n      <div\n        role=\"radiogroup\"\n        aria-label={label}\n        onKeyDown={onKeyDown}\n        className=\"relative\"\n        style={{ width: lado, height: lado }}\n      >\n        {options.map((o, i) => {\n          const on = i === iAtual;\n          const puxe = ima && ima.i === i ? ima : null;\n          return (\n            <motion.button\n              key={o.value}\n              ref={(el) => {\n                botoes.current[i] = el;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={on}\n              aria-label={o.label}\n              tabIndex={on ? 0 : -1}\n              data-state={on ? \"on\" : \"off\"}\n              onClick={() => escolher(i)}\n              onPointerMove={(e) => {\n                if (e.pointerType !== \"mouse\" || pull === 0) return;\n                const b = e.currentTarget.getBoundingClientRect();\n                const k = (pull / 100) * 0.45;\n                setIma({\n                  i,\n                  dx: (e.clientX - (b.left + b.width / 2)) * k,\n                  dy: (e.clientY - (b.top + b.height / 2)) * k,\n                });\n              }}\n              onPointerLeave={() => setIma((m) => (m?.i === i ? null : m))}\n              initial={false}\n              animate={{\n                x: pos[i].x + (puxe?.dx ?? 0),\n                y: pos[i].y + (puxe?.dy ?? 0),\n                scale: on ? escala : 1,\n              }}\n              whileTap={reduzir ? undefined : { scale: (on ? escala : 1) * 0.92 }}\n              transition={mola}\n              className={cn(\n                \"absolute left-1/2 top-1/2 grid place-items-center rounded-[var(--r-pill)] outline-none\",\n                \"transition-[background-color,border-color,color] [transition-duration:var(--dur-fast)] ease-[var(--ease-out)]\",\n                \"focus-visible:[box-shadow:var(--focus)]\",\n                on\n                  ? \"z-10 border border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-foreground)] [box-shadow:var(--e-raised)]\"\n                  : cn(\n                      \"bg-card text-muted-foreground hover:bg-muted hover:text-foreground\",\n                      stroke ? \"border border-border\" : \"border border-transparent\",\n                    ),\n              )}\n              style={{ width: d, height: d, marginLeft: -r, marginTop: -r }}\n            >\n              <span aria-hidden=\"true\" className=\"grid place-items-center [&>svg]:size-[45%]\" style={{ width: d, height: d }}>\n                {o.icon ?? (\n                  <span className=\"text-[length:var(--text-corpo,0.8125rem)] font-semibold\">\n                    {o.label.slice(0, 1)}\n                  </span>\n                )}\n              </span>\n            </motion.button>\n          );\n        })}\n      </div>\n      <span\n        aria-live=\"polite\"\n        className=\"text-[length:var(--text-corpo,0.8125rem)] font-medium text-foreground\"\n      >\n        {options[iAtual]?.label}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Escolha única lúdica entre poucas opções redondas, como humor, cor ou objetivo no onboarding",
      "evite": "Formulário sério ou muitas opções: morph-select; opções em linha, segmented-control"
    }
  },
  "categories": [
    "controles",
    "selecao",
    "radio",
    "ima",
    "hover",
    "escolha"
  ],
  "type": "registry:ui"
}