{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "confetti-button",
  "type": "registry:component",
  "title": "Confetti Button",
  "description": "A button that fires physical confetti: paper pieces that spin in 3D, fall with air drag and flutter.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Buttons"
  ],
  "docs": "Installed in components/furin/confetti-button/. Docs, live controls and props table: https://furin.dev/components/confetti-button/",
  "files": [
    {
      "path": "registry/confetti-button/ConfettiButton.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { ButtonHTMLAttributes, CSSProperties, MouseEvent, ReactNode } from \"react\";\nimport \"./ConfettiButton.css\";\n\nexport interface ConfettiButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"style\" | \"className\"> {\n  /** Texto del botón. */\n  children?: ReactNode;\n  /** Cuántos papelitos salen. */\n  count?: number;\n  /** Colores de los papelitos. */\n  colors?: string[];\n  /** Apertura del abanico, en grados. */\n  spread?: number;\n  /** Dirección central de la explosión, en grados (-90 = hacia arriba). */\n  angle?: number;\n  /** Fuerza del disparo (1 = normal). */\n  power?: number;\n  /** De dónde salen: del punto del clic o del centro del botón. */\n  origin?: \"pointer\" | \"center\";\n  /** Color del fondo del botón. */\n  background?: string;\n  /** Color del texto del botón. */\n  color?: string;\n  /** Clase extra para el botón. */\n  className?: string;\n  /** Estilos extra para el botón. */\n  style?: CSSProperties;\n}\n\ninterface P {\n  x: number; y: number; vx: number; vy: number;\n  w: number; h: number; rot: number; spin: number;\n  tilt: number; tiltSpeed: number; sway: number; swayPhase: number; swaySpeed: number;\n  drag: number; color: string; shape: 0 | 1 | 2; life: number; max: number;\n}\n\n/** Sombrea un color #rgb/#rrggbb (para el reverso del papelito). */\nfunction shade(hex: string, k: number): string {\n  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());\n  if (!m) return hex;\n  let h = m[1];\n  if (h.length === 3) h = h.split(\"\").map((c) => c + c).join(\"\");\n  const n = parseInt(h, 16);\n  const f = (v: number) => Math.max(0, Math.min(255, Math.round(v * k)));\n  return `rgb(${f((n >> 16) & 255)},${f((n >> 8) & 255)},${f(n & 255)})`;\n}\n\n/** Botón que lanza confeti físico: papelitos que giran en 3D, caen con resistencia del aire y se mecen. */\nexport function ConfettiButton({\n  children = \"Celebrate\",\n  count = 120,\n  colors = [\"#ff5470\", \"#ffb84d\", \"#3ddc97\", \"#4da3ff\", \"#a66bff\", \"#ffffff\"],\n  spread = 130,\n  angle = -90,\n  power = 1,\n  origin = \"pointer\",\n  background = \"#f4f4f7\",\n  color = \"#14151a\",\n  className = \"\",\n  style,\n  onClick,\n  ...rest\n}: ConfettiButtonProps) {\n  const ref = useRef<HTMLButtonElement>(null);\n  const sim = useRef<{ cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D; parts: P[]; rings: { x: number; y: number; t: number }[]; raf: number; last: number; dpr: number; onResize: () => void } | null>(null);\n\n  const stop = () => {\n    const s = sim.current;\n    if (!s) return;\n    cancelAnimationFrame(s.raf);\n    window.removeEventListener(\"resize\", s.onResize);\n    s.cv.remove();\n    sim.current = null;\n  };\n\n  useEffect(() => stop, []);\n\n  const frame = (now: number) => {\n    const s = sim.current;\n    if (!s) return;\n    const dt = Math.min(0.033, (now - s.last) / 1000 || 0.016);\n    s.last = now;\n    const { ctx, parts } = s;\n    const W = window.innerWidth, H = window.innerHeight;\n    ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);\n    ctx.clearRect(0, 0, W, H);\n    // Onda de choque del disparo\n    for (let i = s.rings.length - 1; i >= 0; i--) {\n      const r = s.rings[i];\n      r.t += dt;\n      const f = r.t / 0.45;\n      if (f >= 1) { s.rings.splice(i, 1); continue; }\n      ctx.strokeStyle = `rgba(255,255,255,${0.55 * (1 - f)})`;\n      ctx.lineWidth = 3 * (1 - f) + 0.5;\n      ctx.beginPath();\n      ctx.arc(r.x, r.y, 10 + 110 * (1 - Math.pow(1 - f, 3)), 0, Math.PI * 2);\n      ctx.stroke();\n    }\n    for (let i = parts.length - 1; i >= 0; i--) {\n      const p = parts[i];\n      p.life += dt;\n      if (p.life > p.max || p.y > H + 60) { parts.splice(i, 1); continue; }\n      // Resistencia: un papelito de canto cae más rápido que uno de plano.\n      const cosT = Math.cos(p.tilt);\n      const k = p.drag * (0.55 + 0.9 * Math.abs(cosT));\n      p.vx *= Math.exp(-k * dt);\n      p.vy = p.vy * Math.exp(-k * dt) + 620 * dt;\n      p.swayPhase += p.swaySpeed * dt;\n      const settle = Math.min(1, p.life / 0.5);\n      p.x += (p.vx + Math.sin(p.swayPhase) * p.sway * settle) * dt;\n      p.y += p.vy * dt;\n      p.rot += p.spin * dt;\n      p.tilt += p.tiltSpeed * dt;\n      const a = Math.min(1, (p.max - p.life) / 0.7);\n      ctx.save();\n      ctx.translate(p.x, p.y);\n      ctx.rotate(p.rot);\n      ctx.scale(1, cosT);\n      ctx.globalAlpha = a;\n      // El anverso y el reverso no tienen el mismo brillo: eso da la sensación de giro.\n      ctx.fillStyle = cosT >= 0 ? p.color : shade(p.color, 0.72);\n      if (p.shape === 2) { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); }\n      else ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);\n      ctx.restore();\n    }\n    if (parts.length || s.rings.length) s.raf = requestAnimationFrame(frame);\n    else stop();\n  };\n\n  const fire = (cx: number, cy: number) => {\n    if (!sim.current) {\n      const cv = document.createElement(\"canvas\");\n      cv.setAttribute(\"aria-hidden\", \"true\");\n      cv.style.cssText = \"position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483000\";\n      document.body.appendChild(cv);\n      const ctx = cv.getContext(\"2d\");\n      if (!ctx) { cv.remove(); return; }\n      const onResize = () => {\n        const q = sim.current;\n        if (!q) return;\n        q.dpr = Math.min(window.devicePixelRatio || 1, 2);\n        q.cv.width = Math.round(window.innerWidth * q.dpr);\n        q.cv.height = Math.round(window.innerHeight * q.dpr);\n      };\n      sim.current = { cv, ctx, parts: [], rings: [], raf: 0, last: performance.now(), dpr: 1, onResize };\n      onResize();\n      window.addEventListener(\"resize\", onResize);\n      sim.current.raf = requestAnimationFrame(frame);\n    }\n    const s = sim.current;\n    s.rings.push({ x: cx, y: cy, t: 0 });\n    const rnd = (a: number, b: number) => a + Math.random() * (b - a);\n    const pal = colors.length ? colors : [\"#fff\"];\n    for (let i = 0; i < count; i++) {\n      const a = ((angle + rnd(-spread / 2, spread / 2)) * Math.PI) / 180;\n      // La mayoría sale con fuerza media y unos pocos salen disparados.\n      const v = (260 + Math.pow(Math.random(), 1.6) * 1100) * power;\n      const shape = (Math.random() < 0.55 ? 0 : Math.random() < 0.55 ? 1 : 2) as 0 | 1 | 2;\n      const size = rnd(7, 12);\n      s.parts.push({\n        x: cx + Math.cos(a) * 6, y: cy + Math.sin(a) * 6,\n        vx: Math.cos(a) * v, vy: Math.sin(a) * v,\n        w: shape === 1 ? size * 0.55 : size, h: shape === 1 ? size * 2.6 : size * rnd(0.55, 0.9),\n        rot: rnd(0, 6.28), spin: rnd(-9, 9),\n        tilt: rnd(0, 6.28), tiltSpeed: rnd(7, 16) * (Math.random() < 0.5 ? -1 : 1),\n        sway: rnd(18, 70), swayPhase: rnd(0, 6.28), swaySpeed: rnd(3, 7),\n        drag: rnd(2.2, 3.8), color: pal[Math.floor(Math.random() * pal.length)],\n        shape, life: 0, max: rnd(2.6, 4.2),\n      });\n    }\n  };\n\n  const handle = (e: MouseEvent<HTMLButtonElement>) => {\n    onClick?.(e);\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    const r = ref.current?.getBoundingClientRect();\n    if (!r) return;\n    // Un clic con teclado reporta (0,0): ahí se usa el centro del botón.\n    const keyboard = e.clientX === 0 && e.clientY === 0;\n    const fromPointer = origin === \"pointer\" && !keyboard;\n    fire(fromPointer ? e.clientX : r.left + r.width / 2, fromPointer ? e.clientY : r.top + r.height / 2);\n  };\n\n  const vars = { \"--furin-confetti-button-bg\": background, \"--furin-confetti-button-color\": color } as CSSProperties;\n  return (\n    <button ref={ref} type=\"button\" className={`furin-confetti-button ${className}`} style={{ ...vars, ...style }} onClick={handle} {...rest}>\n      <svg className=\"furin-confetti-button__icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n        <path d=\"M10 3.5c.5 4.2 1.6 5.3 5.8 5.8-4.2.5-5.3 1.6-5.8 5.8-.5-4.2-1.6-5.3-5.8-5.8 4.2-.5 5.3-1.6 5.8-5.8Z\" fill=\"currentColor\" />\n        <path d=\"M18 13.5c.3 2.3.9 2.9 3.2 3.2-2.3.3-2.9.9-3.2 3.2-.3-2.3-.9-2.9-3.2-3.2 2.3-.3 2.9-.9 3.2-3.2Z\" fill=\"currentColor\" opacity=\"0.7\" />\n      </svg>\n      <span>{children}</span>\n    </button>\n  );\n}\n\nexport default ConfettiButton;\n",
      "type": "registry:component",
      "target": "components/furin/confetti-button/ConfettiButton.tsx"
    },
    {
      "path": "registry/confetti-button/ConfettiButton.css",
      "content": ".furin-confetti-button {\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  gap: 10px;\n  height: 56px;\n  padding: 0 28px 0 24px;\n  border: 0;\n  border-radius: 999px;\n  color: var(--furin-confetti-button-color);\n  background:\n    linear-gradient(180deg, rgb(255 255 255 / 0.9), rgb(255 255 255 / 0) 55%),\n    var(--furin-confetti-button-bg);\n  box-shadow:\n    inset 0 1px 0 rgb(255 255 255 / 0.95),\n    inset 0 -2px 4px rgb(0 0 0 / 0.1),\n    0 0 0 1px rgb(0 0 0 / 0.08),\n    0 2px 4px rgb(0 0 0 / 0.25),\n    0 14px 26px -8px rgb(0 0 0 / 0.6);\n  font: 600 17px/1 Geist, -apple-system, BlinkMacSystemFont, \"SF Pro Text\", system-ui, 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: al soltar crece de más y se asienta. */\n  transition: transform 0.5s cubic-bezier(0.3, 1.8, 0.5, 1), box-shadow 0.2s;\n}\n.furin-confetti-button:hover { transform: scale(1.04); }\n.furin-confetti-button:active { transform: scale(0.94, 0.9); transition-duration: 0.12s; transition-timing-function: ease-out; box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.9), inset 0 -1px 2px rgb(0 0 0 / 0.1), 0 0 0 1px rgb(0 0 0 / 0.08), 0 8px 14px -8px rgb(0 0 0 / 0.6); }\n.furin-confetti-button:focus-visible { box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.95), 0 0 0 1px rgb(0 0 0 / 0.08), 0 0 0 4px rgb(255 255 255 / 0.5); }\n.furin-confetti-button:disabled { opacity: 0.45; cursor: not-allowed; }\n.furin-confetti-button__icon { width: 22px; height: 22px; flex: none; color: #ff5470; filter: drop-shadow(0 1px 0 rgb(255 255 255 / 0.9)); }\n@media (prefers-reduced-motion: reduce) { .furin-confetti-button { transition: none; } }\n",
      "type": "registry:file",
      "target": "components/furin/confetti-button/ConfettiButton.css"
    }
  ]
}
