{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"confetti-button","type":"registry:component","title":"Confetti Burst Button","description":"A red pill button with a pointer-following sheen that squashes on press and throws a real confetti burst from your click point, then morphs its label into a drawn check and resets.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/confetti-button.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\ntype Piece = {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  rot: number;\n  spin: number;\n  flip: number;\n  flipSpeed: number;\n  sway: number;\n  swayPhase: number;\n  w: number;\n  h: number;\n  shape: 0 | 1 | 2 | 3;\n  color: string;\n  age: number;\n  life: number;\n};\n\ntype Api = { fire: (ox: number, oy: number) => void };\n\nconst DEFAULT_COLORS = [\"#ff4d6d\", \"#ffb703\", \"#2ee6a6\", \"#4cc9f0\", \"#a78bfa\", \"#f472b6\"];\nconst HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;\nconst GRAVITY = 760; // px/s^2\nconst DRAG_X = 4.4; // 1/s, horizontal air drag\nconst DRAG_Y = 2.9; // 1/s, vertical drag, sets the terminal fall speed (~260px/s)\nconst EASE_SPRING = \"cubic-bezier(0.2, 0.9, 0.25, 1)\";\n\nconst rand = (a: number, b: number) => a + Math.random() * (b - a);\n\nexport function ConfettiButton({\n  label = \"Click me\",\n  count = 18,\n  spread = 70,\n  doneLabel = \"Done\",\n  colors,\n  resetAfter = 1900,\n  disabled = false,\n  className = \"\",\n  onClick,\n}: {\n  label?: string;\n  /** Number of confetti pieces per burst (squares, dots, ribbons, triangles). */\n  count?: number;\n  /** How far the burst reaches, in px. */\n  spread?: number;\n  /** Text shown next to the check after a click. */\n  doneLabel?: string;\n  /** Hex colours for the pieces. */\n  colors?: string[];\n  /** Milliseconds the \"Done\" state stays before the label resets. */\n  resetAfter?: number;\n  disabled?: boolean;\n  className?: string;\n  onClick?: () => void;\n}) {\n  const [done, setDone] = useState(false);\n  const btnRef = useRef<HTMLButtonElement>(null);\n  const ringRef = useRef<HTMLSpanElement>(null);\n  const glowRef = useRef<HTMLSpanElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const apiRef = useRef<Api | null>(null);\n  const resetTimer = useRef(0);\n  const reduced = useRef(false);\n  const cfg = useRef({ count, spread, colors: DEFAULT_COLORS });\n\n  useEffect(() => {\n    const valid = (colors ?? []).filter((c) => HEX.test(c));\n    cfg.current = { count, spread, colors: valid.length ? valid : DEFAULT_COLORS };\n  }, [count, spread, colors]);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reduced.current = mq.matches;\n    const on = () => {\n      reduced.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", on);\n    return () => mq.removeEventListener(\"change\", on);\n  }, []);\n\n  useEffect(() => () => window.clearTimeout(resetTimer.current), []);\n\n  // The particle engine lives in one mount effect: it owns the pieces, the canvas and the frame loop.\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    let pieces: Piece[] = [];\n    let raf = 0;\n    let last = 0;\n    let dpr = 1;\n    let size = 0;\n\n    const clear = () => {\n      canvas.width = 0;\n      canvas.height = 0;\n      size = 0;\n    };\n\n    const frame = (t: number) => {\n      const dt = Math.min(0.034, Math.max(0.001, (t - last) / 1000));\n      last = t;\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      const drag = Math.exp(-DRAG_X * dt);\n      const dragY = Math.exp(-DRAG_Y * dt);\n      const keep: Piece[] = [];\n      for (const p of pieces) {\n        p.age += dt;\n        if (p.age >= p.life) continue;\n        p.vx *= drag;\n        p.vy = (p.vy + GRAVITY * dt) * dragY;\n        p.x += (p.vx + Math.sin(p.age * 5.2 + p.swayPhase) * p.sway * Math.min(1, p.age * 2)) * dt;\n        p.y += p.vy * dt;\n        p.rot += p.spin * dt;\n        p.flip += p.flipSpeed * dt;\n        const u = p.age / p.life;\n        const fade = u < 0.62 ? 1 : 1 - (u - 0.62) / 0.38;\n        const tilt = Math.cos(p.flip);\n        ctx.save();\n        ctx.translate(p.x + size, p.y + size);\n        ctx.rotate(p.rot);\n        ctx.scale(1, tilt);\n        ctx.globalAlpha = Math.max(0, fade) * (0.72 + 0.28 * Math.abs(tilt));\n        ctx.fillStyle = p.color;\n        if (p.shape === 0) {\n          ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);\n        } else if (p.shape === 1) {\n          ctx.beginPath();\n          ctx.arc(0, 0, p.h * 0.62, 0, Math.PI * 2);\n          ctx.fill();\n        } else if (p.shape === 2) {\n          ctx.fillRect(-p.w * 1.1, -1.3, p.w * 2.2, 2.6);\n        } else {\n          ctx.beginPath();\n          ctx.moveTo(0, -p.h * 0.8);\n          ctx.lineTo(p.w * 0.6, p.h * 0.6);\n          ctx.lineTo(-p.w * 0.6, p.h * 0.6);\n          ctx.closePath();\n          ctx.fill();\n        }\n        ctx.restore();\n        keep.push(p);\n      }\n      pieces = keep;\n      if (pieces.length) {\n        raf = requestAnimationFrame(frame);\n      } else {\n        raf = 0;\n        clear();\n      }\n    };\n\n    apiRef.current = {\n      fire(ox, oy) {\n        const { count: n, spread: s, colors: palette } = cfg.current;\n        const reach = Math.max(20, s);\n        const R = Math.round(reach * 1.9 + 280);\n        dpr = Math.min(2, window.devicePixelRatio || 1);\n        if (size !== R) {\n          size = R;\n          canvas.width = Math.round(R * 2 * dpr);\n          canvas.height = Math.round(R * 2 * dpr);\n          canvas.style.width = R * 2 + \"px\";\n          canvas.style.height = R * 2 + \"px\";\n          canvas.style.margin = -R + \"px 0 0 \" + -R + \"px\";\n        }\n        const total = Math.max(1, Math.round(n));\n        for (let i = 0; i < total; i++) {\n          const up = Math.random() < 0.72;\n          const a = up ? -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 1.55 : Math.random() * Math.PI * 2;\n          const speed = reach * DRAG_X * rand(0.38, 1.18);\n          const shape = ([0, 0, 1, 2, 3] as const)[Math.floor(Math.random() * 5)] ?? 0;\n          pieces.push({\n            x: ox + Math.cos(a) * 6,\n            y: oy + Math.sin(a) * 6,\n            vx: Math.cos(a) * speed,\n            vy: Math.sin(a) * speed,\n            rot: rand(0, Math.PI * 2),\n            spin: rand(-9, 9),\n            flip: rand(0, Math.PI * 2),\n            flipSpeed: rand(6, 17),\n            sway: rand(10, 42),\n            swayPhase: rand(0, 6.28),\n            w: rand(6, 11),\n            h: rand(4, 7.5),\n            shape,\n            color: palette[Math.floor(Math.random() * palette.length)] ?? \"#fff\",\n            age: 0,\n            life: rand(1.35, 2.3),\n          });\n        }\n        if (!raf) {\n          last = performance.now();\n          raf = requestAnimationFrame(frame);\n        }\n      },\n    };\n\n    return () => {\n      apiRef.current = null;\n      cancelAnimationFrame(raf);\n      raf = 0;\n    };\n  }, []);\n\n  const move = useCallback((e: React.PointerEvent<HTMLButtonElement>) => {\n    const el = btnRef.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    const k = r.width ? el.offsetWidth / r.width : 1;\n    el.style.setProperty(\"--cb-x\", (e.clientX - r.left) * k + \"px\");\n    el.style.setProperty(\"--cb-y\", (e.clientY - r.top) * k + \"px\");\n  }, []);\n\n  const click = (e: React.MouseEvent<HTMLButtonElement>) => {\n    const el = btnRef.current;\n    if (!el || disabled) return;\n    const r = el.getBoundingClientRect();\n    const k = r.width ? el.offsetWidth / r.width : 1;\n    const w = el.offsetWidth;\n    const h = el.offsetHeight;\n    // Pointer clicks burst from the pointer; keyboard activation (detail 0) bursts from the centre.\n    const px = e.detail === 0 ? w / 2 : Math.min(w, Math.max(0, (e.clientX - r.left) * k));\n    const py = e.detail === 0 ? h / 2 : Math.min(h, Math.max(0, (e.clientY - r.top) * k));\n    const calm = reduced.current;\n    if (!calm) {\n      apiRef.current?.fire(px - w / 2, py - h / 2);\n      el.animate(\n        [\n          { transform: \"scale(1, 1)\" },\n          { transform: \"scale(1.08, 0.93)\", offset: 0.28 },\n          { transform: \"scale(0.98, 1.03)\", offset: 0.58 },\n          { transform: \"scale(1, 1)\" },\n        ],\n        { duration: 560, easing: EASE_SPRING },\n      );\n      ringRef.current?.animate(\n        [\n          { opacity: 0.55, transform: \"scale(1)\" },\n          { opacity: 0, transform: \"scale(1.55, 1.9)\" },\n        ],\n        { duration: 720, easing: \"cubic-bezier(0.1, 0.7, 0.2, 1)\" },\n      );\n    } else {\n      glowRef.current?.animate([{ opacity: 0 }, { opacity: 1, offset: 0.35 }, { opacity: 0 }], {\n        duration: 900,\n        easing: \"ease-in-out\",\n      });\n    }\n    setDone(true);\n    window.clearTimeout(resetTimer.current);\n    resetTimer.current = window.setTimeout(() => setDone(false), Math.max(400, resetAfter));\n    onClick?.();\n  };\n\n  const swap = (on: boolean) => ({\n    opacity: on ? 1 : 0,\n    transform: on ? \"translateY(0) scale(1)\" : \"translateY(\" + (done ? \"-9px\" : \"9px\") + \") scale(0.94)\",\n    filter: on ? \"blur(0px)\" : \"blur(4px)\",\n    transition:\n      \"opacity 320ms \" + EASE_SPRING + \", transform 420ms \" + EASE_SPRING + \", filter 320ms \" + EASE_SPRING,\n  });\n\n  return (\n    <span className={\"relative inline-flex \" + className}>\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute left-1/2 top-1/2 z-10\" width={0} height={0} />\n      <span\n        ref={ringRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 opacity-0\"\n        style={{ borderRadius: 9999, boxShadow: \"0 0 0 1.5px rgba(239,68,68,0.7), 0 0 24px 2px rgba(239,68,68,0.35)\" }}\n      />\n      <span\n        ref={glowRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute -inset-1 opacity-0\"\n        style={{ borderRadius: 9999, boxShadow: \"0 0 0 2px rgba(46,230,166,0.8), 0 0 36px 6px rgba(46,230,166,0.5)\" }}\n      />\n      <button\n        ref={btnRef}\n        type=\"button\"\n        disabled={disabled}\n        onClick={click}\n        onPointerMove={move}\n        className=\"group relative inline-flex min-h-12 cursor-pointer select-none items-center justify-center overflow-hidden whitespace-nowrap px-8 text-[15px] font-semibold tracking-[-0.01em] transition-[scale,translate,box-shadow] duration-150 enabled:hover:-translate-y-px enabled:active:scale-[0.95] disabled:cursor-not-allowed disabled:opacity-45 focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-4 focus-visible:!outline-[#6366f1] [-webkit-tap-highlight-color:transparent] touch-manipulation\"\n        style={{\n          borderRadius: 9999,\n          color: \"#ffffff\",\n          background: \"linear-gradient(180deg, #ff4a4a 0%, #e0141f 100%)\",\n          boxShadow:\n            \"inset 0 1px 0 rgba(255,255,255,0.4), inset 0 -1px 0 rgba(90,0,8,0.25), 0 12px 26px -8px rgba(224,20,31,0.55)\",\n          [\"--cb-x\" as string]: \"50%\",\n          [\"--cb-y\" as string]: \"50%\",\n        }}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-enabled:group-hover:opacity-100\"\n          style={{ background: \"radial-gradient(130px circle at var(--cb-x) var(--cb-y), rgba(255,255,255,0.34), rgba(255,255,255,0.1) 45%, transparent 72%)\" }}\n        />\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0\"\n          style={{\n            background: \"linear-gradient(180deg, #2fd27a 0%, #15a05a 100%)\",\n            opacity: done ? 1 : 0,\n            transition: \"opacity 380ms ease\",\n          }}\n        />\n        <span className=\"relative grid place-items-center\">\n          <span className=\"col-start-1 row-start-1 inline-flex items-center gap-2\" style={swap(!done)} aria-hidden={done}>\n            {label}\n          </span>\n          <span className=\"col-start-1 row-start-1 inline-flex items-center gap-2\" style={swap(done)} aria-hidden={!done}>\n            <svg width=\"17\" height=\"17\" viewBox=\"0 0 20 20\" fill=\"none\" aria-hidden=\"true\">\n              <path\n                d=\"M4.5 10.5l3.6 3.6 7.4-8\"\n                stroke=\"#ffffff\"\n                strokeWidth=\"2.4\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeDasharray=\"22\"\n                style={{ strokeDashoffset: done ? 0 : 22, transition: \"stroke-dashoffset 420ms cubic-bezier(0.3, 0.7, 0.2, 1) \" + (done ? \"120ms\" : \"0ms\") }}\n              />\n            </svg>\n            {doneLabel}\n          </span>\n        </span>\n      </button>\n      <span role=\"status\" className=\"sr-only\">\n        {done ? doneLabel : \"\"}\n      </span>\n    </span>\n  );\n}\n","type":"registry:component","target":"components/viberdy/confetti-button.tsx"}],"meta":{"tier":"free","tags":["button","confetti","click","celebration","canvas","success","particles"],"href":"https://viberdy.dev/library/confetti-button","aiPrompt":"Create a React + Tailwind (v4) \"confetti button\" component named ConfettiButton. One self-contained \"use client\" file, React only, no confetti library, no framer-motion. It paints its own surfaces (no site colour tokens) and inherits the page font.\n\nProps (all optional): label = \"Click me\"; count = 18 (number of confetti pieces per burst); spread = 70 (how far the burst reaches in px); doneLabel = \"Done\"; colors (array of hex strings, default [\"#ff4d6d\",\"#ffb703\",\"#2ee6a6\",\"#4cc9f0\",\"#a78bfa\",\"#f472b6\"], invalid entries are dropped with /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i); resetAfter = 1900 (ms the Done state stays); disabled = false; className (applied to the outer inline-flex wrapper); onClick.\n\nStructure: outer span `relative inline-flex`, containing (1) an absolutely positioned, pointer-events-none Canvas 2D overlay centred on the button (z-10), (2) a ring span (rounded-full, 1.5px red outline + soft red glow, opacity 0), (3) a mint glow span (rounded-full, 2px emerald outline + glow, opacity 0, used only for reduced motion), (4) the button, (5) an sr-only role=\"status\" span that says doneLabel when done.\n\nButton: pill, min-height 48px, px 32px, 15px / 600 / -0.01em tracking, white text on a red vertical gradient (#ff4a4a to #e0141f, the original red, so it works on light and dark pages), inset top highlight, soft red drop shadow. Hover: lift 1px and a pointer-following white sheen (radial 130px circle at the pointer in layout pixels, rgba(255,255,255,.34) to transparent). Active: scale 0.95 (150ms, via the `scale` property). Focus-visible: 2px indigo #6366f1 outline (visible on light and dark), offset 4px, forced rounded-full with important utilities. Disabled: 45% opacity. Done state fades in a green gradient (#2fd27a to #15a05a) over 380ms.\n\nLabel morph: both labels sit in one grid cell so the button never changes width. The active one is opacity 1, un-blurred; the other is opacity 0, blur 4px, offset 9px vertically (idle label exits upward, Done label waits below) and scale .94, transitioning 320-420ms on cubic-bezier(.2,.9,.25,1). The Done label is a 17px check (stroke white, width 2.4, round caps, draws with stroke-dashoffset 22 to 0 over 420ms after a 120ms delay) plus doneLabel.\n\nOn click: record the click point (clamped to the button; keyboard activation, event.detail===0, uses the centre) and launch count pieces from there; run a Web Animations squash-and-settle on the button (scale 1 -> 1.08,0.93 at 28% -> 0.98,1.03 at 58% -> 1, 560ms) and the ring (opacity .55 -> 0, scale 1 -> 1.55x1.9, 720ms); set done=true and a timer resets it after resetAfter (clicking again restarts the timer and fires again).\n\nParticle engine (Canvas 2D, one rAF loop inside a single mount effect, the loop only runs while pieces exist, canvas backing store set to 0x0 when idle; DPR capped at 2). Canvas half-size R = spread*1.9+280 px. Each piece: 72% launch in an upward fan (-90deg +/- 140deg), 28% in any direction; launch speed = spread*4.4*rand(0.38,1.18) px/s; gravity 760 px/s^2; horizontal drag exp(-4.4 dt); vertical (v+g dt)*exp(-2.9 dt) so pieces settle to a ~260 px/s flutter; sideways sway sin(age*5.2+phase)*rand(10,42) ramped in over 0.5s; spin +/-9 rad/s; a \"flip\" angle advancing 6-17 rad/s whose cosine scales the piece's Y so it tumbles like paper and darkens when edge-on (alpha 0.72 + 0.28|cos|). Lifetime 1.35-2.3s, full opacity for 62% then linear fade. Shapes: rectangle 6-11 x 4-7.5 (40%), dot radius ~h*.62 (20%), thin ribbon 2.6px high and 2.2x the width (20%), triangle (20%). Colours chosen at random from the palette. dt clamped to 34ms. Randomness only runs in the click handler.\n\nReduced motion (matchMedia read in an effect, kept in a ref and updated on change): no particles, no squash, no ring; instead the mint glow span pulses once (opacity 0 -> 1 at 35% -> 0 over 900ms) and the label still swaps to Done.\n\nA11y: native button; the idle label is aria-hidden while done and the Done label aria-hidden while idle; the role=status span announces the Done text; canvas is aria-hidden; touch-manipulation and no tap highlight; 48px target. Clean up the reset timer and rAF on unmount.","license":"https://viberdy.dev/license"}}