{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glass-button",
  "type": "registry:component",
  "title": "Glass Button",
  "description": "A liquid-glass button that refracts the background behind it.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Buttons"
  ],
  "docs": "Installed in components/furin/glass-button/. Docs, live controls and props table: https://furin.dev/components/glass-button/",
  "files": [
    {
      "path": "registry/glass-button/GlassButton.tsx",
      "content": "\"use client\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport \"./GlassButton.css\";\n\n/* ---------- Refracción real (solo Chromium): mapa de desplazamiento generado en canvas ---------- */\n\n/** Solo Chromium acepta url(#filtro) dentro de backdrop-filter; el resto cae al desenfoque simple. */\nfunction canRefract(): boolean {\n  if (typeof CSS === \"undefined\" || typeof navigator === \"undefined\") return false;\n  return /Chrome\\//.test(navigator.userAgent) && CSS.supports(\"backdrop-filter\", \"url(#a)\");\n}\n\ninterface RefractionMap { url: string; scale: number; w: number; h: number }\n\n/** Mapa de desplazamiento: en el bisel la luz se curva hacia adentro y, con zoom > 1, el centro también amplía. */\nfunction buildRefractionMap(w: number, h: number, radius: number, bezel: number, zoom = 1): RefractionMap | null {\n  if (w < 4 || h < 4) return null;\n  const c = document.createElement(\"canvas\");\n  c.width = w;\n  c.height = h;\n  const ctx = c.getContext(\"2d\");\n  if (!ctx) return null;\n  const img = ctx.createImageData(w, h);\n  const vx = new Float32Array(w * h);\n  const vy = new Float32Array(w * h);\n  const hw = w / 2, hh = h / 2, r = Math.min(radius, hw, hh);\n  const k = 1 - 1 / zoom;\n  let max = 0.001;\n  for (let y = 0; y < h; y++) {\n    for (let x = 0; x < w; x++) {\n      const px = x + 0.5 - hw, py = y + 0.5 - hh;\n      const qx = Math.abs(px) - (hw - r), qy = Math.abs(py) - (hh - r);\n      const sdf = Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;\n      if (sdf > 0) continue;\n      let nx: number, ny: number;\n      if (qx > 0 && qy > 0) { const l = Math.hypot(qx, qy) || 1; nx = (Math.sign(px) * qx) / l; ny = (Math.sign(py) * qy) / l; }\n      else if (qx > qy) { nx = Math.sign(px); ny = 0; }\n      else { nx = 0; ny = Math.sign(py); }\n      const t = Math.min(1, -sdf / bezel);\n      const m = Math.pow(1 - t, 2.2) * bezel;\n      const dx = -nx * m - px * k, dy = -ny * m - py * k;\n      const i = y * w + x;\n      vx[i] = dx;\n      vy[i] = dy;\n      max = Math.max(max, Math.abs(dx), Math.abs(dy));\n    }\n  }\n  const d = img.data;\n  for (let i = 0; i < w * h; i++) {\n    d[i * 4] = Math.round(127.5 + (127.5 * vx[i]) / max);\n    d[i * 4 + 1] = Math.round(127.5 + (127.5 * vy[i]) / max);\n    d[i * 4 + 2] = 128;\n    d[i * 4 + 3] = 255;\n  }\n  ctx.putImageData(img, 0, 0);\n  return { url: c.toDataURL(\"image/png\"), scale: max * 2, w, h };\n}\n\n/** Mide el elemento y mantiene su mapa de refracción al día (solo si el navegador lo soporta). */\nfunction useRefractionMap(ref: React.RefObject<HTMLElement | null>, radius: number, bezel: number, zoom = 1): RefractionMap | null {\n  const [mapa, setMapa] = useState<RefractionMap | null>(null);\n  useEffect(() => {\n    if (!canRefract()) return;\n    const el = ref.current;\n    if (!el) return;\n    let raf = 0;\n    const run = () => {\n      const m = buildRefractionMap(el.offsetWidth, el.offsetHeight, radius, bezel, zoom);\n      if (m) setMapa((p) => (p && p.w === m.w && p.h === m.h ? p : m));\n    };\n    const ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(run); });\n    ro.observe(el);\n    run();\n    return () => { ro.disconnect(); cancelAnimationFrame(raf); };\n  }, [ref, radius, bezel, zoom]);\n  return mapa;\n}\n\n/** Filtro SVG: desenfoque, desplazamiento por el mapa y saturación; con dispersión separa los canales RGB como un cristal real. */\nfunction RefractionFilter({ id, mapa, strength, blur = 0, saturate = 1.6, dispersion = 0 }: { id: string; mapa: RefractionMap; strength: number; blur?: number; saturate?: number; dispersion?: number }) {\n  const s = mapa.scale * strength;\n  const disp = (scale: number, result: string) => (\n    <feDisplacementMap in=\"src\" in2=\"mapa\" scale={scale} xChannelSelector=\"R\" yChannelSelector=\"G\" result={result} />\n  );\n  return (\n    <svg width=\"0\" height=\"0\" style={{ position: \"absolute\" }} aria-hidden=\"true\" focusable=\"false\">\n      <filter id={id} x=\"0\" y=\"0\" width={mapa.w} height={mapa.h} filterUnits=\"userSpaceOnUse\" colorInterpolationFilters=\"sRGB\">\n        <feImage href={mapa.url} x=\"0\" y=\"0\" width={mapa.w} height={mapa.h} preserveAspectRatio=\"none\" result=\"mapa\" />\n        <feGaussianBlur in=\"SourceGraphic\" stdDeviation={Math.max(blur, 0.01)} result=\"src\" />\n        {dispersion > 0 ? (\n          <>\n            {disp(s * (1 + dispersion), \"dr\")}\n            <feColorMatrix in=\"dr\" type=\"matrix\" values=\"1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0\" result=\"r\" />\n            {disp(s, \"dg\")}\n            <feColorMatrix in=\"dg\" type=\"matrix\" values=\"0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0\" result=\"g\" />\n            {disp(s * (1 - dispersion), \"db\")}\n            <feColorMatrix in=\"db\" type=\"matrix\" values=\"0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0\" result=\"b\" />\n            <feBlend in=\"r\" in2=\"g\" mode=\"screen\" result=\"rg\" />\n            <feBlend in=\"rg\" in2=\"b\" mode=\"screen\" result=\"rgb\" />\n          </>\n        ) : (\n          disp(s, \"rgb\")\n        )}\n        <feColorMatrix in=\"rgb\" type=\"saturate\" values={String(saturate)} />\n      </filter>\n    </svg>\n  );\n}\n\n/** Valor de backdrop-filter: el filtro SVG si hay refracción; desenfoque + saturación si no. */\nfunction glassBackdrop(id: string, mapa: RefractionMap | null, blur: number, saturate = 1.6): string {\n  return mapa ? `url(#${id})` : `blur(${blur}px) saturate(${saturate}) brightness(1.06)`;\n}\n\n\nexport interface GlassButtonProps {\n  /** Texto del botón. */\n  children?: ReactNode;\n  /** Alto del botón: pequeño, mediano o grande. */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Muestra una flecha al final del texto. */\n  arrow?: boolean;\n  /** Desenfoque del fondo detrás del vidrio, en px. */\n  blur?: number;\n  /** Fuerza de la refracción en el borde (0 = sin curvar la luz). */\n  refraction?: number;\n  /** Color del tinte del vidrio. */\n  tint?: string;\n  /** Color del texto. */\n  color?: string;\n  /** Pinta una escena de color animada detrás (para verlo refractar). */\n  scene?: boolean;\n  /** Desactiva el botón. */\n  disabled?: boolean;\n  /** Se llama al hacer clic. */\n  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;\n  /** Clase extra para el botón. */\n  className?: string;\n  /** Estilos extra para el botón. */\n  style?: CSSProperties;\n}\n\nconst HEIGHT = { sm: 40, md: 54, lg: 68 } as const;\n\n/** Botón de vidrio líquido: refracta el fondo, brilla donde lo toca la luz y se aplasta como una gota al pulsar. */\nexport function GlassButton({\n  children,\n  size = \"md\",\n  arrow = true,\n  blur = 1.2,\n  refraction = 1,\n  tint = \"rgba(255,255,255,0.12)\",\n  color = \"#fff\",\n  scene = false,\n  disabled = false,\n  onClick,\n  className = \"\",\n  style,\n}: GlassButtonProps) {\n  const btn = useRef<HTMLButtonElement>(null);\n  const glass = useRef<HTMLSpanElement>(null);\n  const fid = \"furin-glass-button-\" + useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  const h = HEIGHT[size];\n  const mapa = useRefractionMap(glass, h / 2, h * 0.3);\n  const [onda, setOnda] = useState(0);\n\n  // La luz sigue al cursor sobre el botón (sin resorte: es un destello, no una masa).\n  useEffect(() => {\n    const el = btn.current;\n    if (!el) return;\n    const move = (e: PointerEvent) => {\n      const b = el.getBoundingClientRect();\n      el.style.setProperty(\"--furin-glass-button-mx\", `${e.clientX - b.left}px`);\n      el.style.setProperty(\"--furin-glass-button-my\", `${e.clientY - b.top}px`);\n    };\n    el.addEventListener(\"pointermove\", move);\n    el.addEventListener(\"pointerdown\", move);\n    return () => {\n      el.removeEventListener(\"pointermove\", move);\n      el.removeEventListener(\"pointerdown\", move);\n    };\n  }, []);\n\n  const vars = { \"--furin-glass-button-h\": `${h}px`, \"--furin-glass-button-tint\": tint, color } as CSSProperties;\n  const back = glassBackdrop(fid, mapa, blur);\n  return (\n    <>\n      {scene ? <Scene /> : null}\n      {mapa ? <RefractionFilter id={fid} mapa={mapa} strength={refraction} blur={blur} dispersion={0.14} /> : null}\n      <button\n        ref={btn}\n        type=\"button\"\n        disabled={disabled}\n        className={`furin-glass-button furin-glass-button--${size} ${className}`}\n        style={{ ...vars, ...style }}\n        onPointerDown={() => setOnda((n) => n + 1)}\n        onClick={onClick}\n      >\n        <span ref={glass} className=\"furin-glass-button__glass\" style={{ backdropFilter: back, WebkitBackdropFilter: back }} />\n        <span className=\"furin-glass-button__sheen\" aria-hidden=\"true\" />\n        <span key={onda} className={`furin-glass-button__onda${onda ? \" is-on\" : \"\"}`} aria-hidden=\"true\" />\n        <span className=\"furin-glass-button__rim\" aria-hidden=\"true\" />\n        <span className=\"furin-glass-button__label\">\n          {children}\n          {arrow ? (\n            <svg className=\"furin-glass-button__arrow\" viewBox=\"0 0 20 20\" width=\"18\" height=\"18\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n              <path d=\"M4 10h11M11 5l5 5-5 5\" />\n            </svg>\n          ) : null}\n        </span>\n      </button>\n    </>\n  );\n}\n\nfunction Scene() {\n  return (\n    <div className=\"furin-glass-button-scene\" aria-hidden=\"true\">\n      <i /><i /><i /><i /><b />\n    </div>\n  );\n}\n\nexport default GlassButton;\n",
      "type": "registry:component",
      "target": "components/furin/glass-button/GlassButton.tsx"
    },
    {
      "path": "registry/glass-button/GlassButton.css",
      "content": ".furin-glass-button {\n  --furin-glass-button-mx: 30%;\n  --furin-glass-button-my: -20px;\n  position: relative;\n  z-index: 1;\n  display: inline-grid;\n  place-items: center;\n  height: var(--furin-glass-button-h);\n  padding: 0 calc(var(--furin-glass-button-h) * 0.55);\n  border: 0;\n  border-radius: 999px;\n  background: none;\n  font: 600 16px/1 Geist, system-ui, -apple-system, sans-serif;\n  letter-spacing: -0.01em;\n  cursor: pointer;\n  user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  outline: none;\n  transform: scale(1);\n  /* Resorte con rebote: sube, se pasa un poco y se asienta. */\n  transition: transform 0.55s cubic-bezier(0.3, 1.7, 0.5, 1);\n}\n.furin-glass-button--sm { font-size: 14px; }\n.furin-glass-button--lg { font-size: 19px; }\n.furin-glass-button:hover { transform: scale(1.06); }\n.furin-glass-button:active { transform: scale(0.93, 0.9); transition-duration: 0.14s; transition-timing-function: ease-out; }\n.furin-glass-button:focus-visible { box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.55); }\n.furin-glass-button:disabled { cursor: not-allowed; opacity: 0.45; }\n.furin-glass-button__glass {\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background:\n    linear-gradient(160deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.04) 50%, rgba(255, 255, 255, 0.1)),\n    var(--furin-glass-button-tint);\n  /* Sombra de contacto fuera del borde (un filter en el padre rompería el backdrop-filter). */\n  box-shadow:\n    0 14px 22px -10px rgba(5, 8, 30, 0.55),\n    0 3px 6px -1px rgba(5, 8, 30, 0.3),\n    inset 0 -8px 14px -6px rgba(255, 255, 255, 0.22),\n    inset 0 10px 14px -10px rgba(255, 255, 255, 0.4);\n}\n.furin-glass-button__sheen {\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  pointer-events: none;\n  background: radial-gradient(90px circle at var(--furin-glass-button-mx) var(--furin-glass-button-my), rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) 70%);\n  opacity: 0;\n  transition: opacity 0.3s;\n}\n.furin-glass-button:hover .furin-glass-button__sheen { opacity: 1; }\n.furin-glass-button__onda {\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  pointer-events: none;\n  opacity: 0;\n  background: radial-gradient(circle at var(--furin-glass-button-mx) var(--furin-glass-button-my), rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0) 55%);\n}\n.furin-glass-button__onda.is-on { animation: furin-glass-button-onda 0.75s ease-out; }\n@keyframes furin-glass-button-onda {\n  from { opacity: 0.9; clip-path: circle(0 at var(--furin-glass-button-mx) var(--furin-glass-button-my)); }\n  to { opacity: 0; clip-path: circle(260px at var(--furin-glass-button-mx) var(--furin-glass-button-my)); }\n}\n.furin-glass-button__rim {\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  padding: 1.3px;\n  pointer-events: none;\n  background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.12) 30%, rgba(255, 255, 255, 0.05) 62%, rgba(255, 255, 255, 0.6) 100%);\n  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);\n  -webkit-mask-composite: xor;\n  mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);\n}\n.furin-glass-button__label {\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  gap: 9px;\n  text-shadow: 0 1px 10px rgba(10, 14, 50, 0.4);\n  white-space: nowrap;\n}\n.furin-glass-button__arrow { transition: transform 0.45s cubic-bezier(0.3, 1.7, 0.5, 1); }\n.furin-glass-button:hover .furin-glass-button__arrow { transform: translateX(4px); }\n@media (prefers-reduced-motion: reduce) {\n  .furin-glass-button, .furin-glass-button__arrow { transition: none; }\n  .furin-glass-button__onda.is-on { animation: none; }\n}\n\n/* Escena de color: formas con borde nítido para que la refracción se note (solo se pinta con scene). */\n.furin-glass-button-scene { position: absolute; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; background: linear-gradient(160deg, #0d1236 0%, #1b0f3d 55%, #0b2335 100%); }\n.furin-glass-button-scene i { position: absolute; border-radius: 50%; animation: furin-glass-button-flota var(--t, 14s) ease-in-out infinite alternate; }\n.furin-glass-button-scene i:nth-child(1) { --t: 13s; --dx: 80px; --dy: 40px; width: 190px; height: 190px; left: 6%; top: 10%; background: radial-gradient(circle at 32% 28%, #ffd0ea 0, #ff5aa5 42%, #c21f78 100%); }\n.furin-glass-button-scene i:nth-child(2) { --t: 17s; --dx: -70px; --dy: -50px; width: 230px; height: 230px; right: 0%; top: 28%; background: radial-gradient(circle at 34% 30%, #bcd3ff, #4f7dff 45%, #2437c9); }\n.furin-glass-button-scene i:nth-child(3) { --t: 11s; --dx: 90px; --dy: -60px; width: 150px; height: 150px; left: 30%; bottom: -4%; background: radial-gradient(circle at 34% 30%, #d3fff3, #37e0b9 45%, #0a8e86); }\n.furin-glass-button-scene i:nth-child(4) { --t: 9s; --dx: -60px; --dy: 70px; width: 90px; height: 90px; right: 28%; top: 4%; background: radial-gradient(circle at 34% 30%, #fff2cc, #ffb347 50%, #e6700f); }\n.furin-glass-button-scene b { position: absolute; inset: -10% -14%; background: repeating-linear-gradient(105deg, transparent 0 44px, rgba(255, 255, 255, 0.22) 44px 47px); animation: furin-glass-button-lineas 12s ease-in-out infinite alternate; }\n@keyframes furin-glass-button-flota { to { transform: translate(var(--dx), var(--dy)) scale(1.1); } }\n@keyframes furin-glass-button-lineas { to { transform: translateX(-70px); } }\n@media (prefers-reduced-motion: reduce) { .furin-glass-button-scene i, .furin-glass-button-scene b { animation: none; } }\n",
      "type": "registry:file",
      "target": "components/furin/glass-button/GlassButton.css"
    }
  ]
}
