{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button",
  "title": "Premium Snow Button",
  "description": "A cinematic button with realistic snowflake particle effects rendered on a canvas.",
  "files": [
    {
      "path": "components/library/button.tsx",
      "content": "\"use client\";\n\nimport React, { useRef, useCallback, useEffect, useState } from \"react\";\n\ninterface Flake {\n    x: number;\n    y: number;\n    vx: number;\n    vy: number;\n    size: number;\n    opacity: number;\n    life: number;\n    rotation: number;\n    rotSpeed: number;\n    wind: number;\n    // Premium rendering properties\n    depth: number;        // 0 = far/sharp, 1 = near/soft — immutable after spawn\n    sparklePhase: number; // fixed phase for subtle sparkle\n    hueBase: number;      // base hue (205-225), shifts warmer as life fades\n    driftSeed: number;    // unique seed for sinusoidal lateral drift\n    spawnX: number;       // origin x for ambient glow\n    spawnY: number;       // origin y for ambient glow\n    // Pre-computed at spawn — depth and size never change, so this is immutable.\n    // Eliminates a multiply + multiply per flake per frame from the hot path.\n    glowRadius: number;\n}\n\n/**\n * Draw a cinematic snowflake with light-scatter glow,\n * color temperature shift, velocity-based luminance,\n * crystal arms, and subtle sparkle.\n */\nfunction drawPremiumFlake(\n    ctx: CanvasRenderingContext2D,\n    f: Flake,\n    time: number,\n) {\n    const alpha = f.opacity * f.life;\n    if (alpha <= 0.01) return;\n\n    const r = f.size;\n\n    // ── Velocity-based luminance boost ──\n    const speed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);\n    const velBoost = Math.min(speed * 0.08, 0.15);\n\n    // ── Color temperature shift ──\n    // Fresh → warm white  |  Aging → icy blue\n    const age = 1 - f.life;\n    const hue = f.hueBase + age * 10;   // 205-235 range\n    const sat = 40 + age * 25;          // 40% → 65%\n    const lum = 92 - age * 7;           // 92% → 85%\n\n    // ── Depth-of-field opacity (soft, not dramatic) ──\n    const dofAlpha = alpha * (0.75 + f.depth * 0.25);\n\n    ctx.save();\n    ctx.translate(f.x, f.y);\n\n    // ── Layer 1: Glow halo ──\n    // \"lighter\" (additive) pops on both dark and light backgrounds.\n    // glowRadius is pre-computed at spawn (f.glowRadius) — no per-frame math.\n    // Guard: skip the two gradient allocations entirely when the peak alpha is\n    // below the perception threshold, a common case as flakes age out.\n    const glowPeak = dofAlpha * 0.18 + velBoost * 0.05;\n    if (glowPeak > 0.008) {\n        ctx.globalCompositeOperation = \"lighter\";\n        const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, f.glowRadius);\n        glow.addColorStop(0, `hsla(${hue}, ${sat}%, ${lum}%, ${glowPeak})`);\n        glow.addColorStop(0.4, `hsla(${hue}, ${sat * 0.7}%, ${lum - 3}%, ${glowPeak * 0.3})`);\n        glow.addColorStop(1, `hsla(${hue}, ${sat * 0.5}%, ${lum - 6}%, 0)`);\n        ctx.fillStyle = glow;\n        ctx.beginPath();\n        ctx.arc(0, 0, f.glowRadius, 0, Math.PI * 2);\n        ctx.fill();\n    }\n\n    // ── Layer 2: Crystal body (6-armed) ──\n    ctx.globalCompositeOperation = \"source-over\";\n    ctx.rotate(f.rotation);\n\n    const arms = 6;\n    const armLen = r;\n    const branchLen = r * 0.3;\n    const lineW = Math.max(0.6, r * 0.14);\n\n    for (let i = 0; i < arms; i++) {\n        const angle = (Math.PI * 2 * i) / arms;\n        ctx.save();\n        ctx.rotate(angle);\n\n        // Main arm: pure white root → icy blue tip\n        const armGrad = ctx.createLinearGradient(0, 0, armLen, 0);\n        armGrad.addColorStop(0, `hsla(0, 0%, 100%, ${dofAlpha * 0.9 + velBoost})`);\n        armGrad.addColorStop(1, `hsla(${hue}, ${sat}%, ${lum}%, ${dofAlpha * 0.5})`);\n\n        ctx.strokeStyle = armGrad;\n        ctx.lineWidth = lineW;\n        ctx.lineCap = \"round\";\n        ctx.beginPath();\n        ctx.moveTo(r * 0.12, 0);\n        ctx.lineTo(armLen, 0);\n        ctx.stroke();\n\n        // Branches — asymmetric for naturalism\n        ctx.strokeStyle = `hsla(0, 0%, 100%, ${dofAlpha * 0.65})`;\n        ctx.lineWidth = lineW * 0.75;\n\n        const branchAnchor = armLen * 0.55;\n        ctx.beginPath();\n        ctx.moveTo(branchAnchor, 0);\n        ctx.lineTo(\n            branchAnchor + branchLen * Math.cos(-Math.PI / 4.8),\n            branchLen * Math.sin(-Math.PI / 4.8),\n        );\n        ctx.stroke();\n\n        ctx.beginPath();\n        ctx.moveTo(branchAnchor, 0);\n        ctx.lineTo(\n            branchAnchor + branchLen * Math.cos(Math.PI / 5.2),\n            branchLen * Math.sin(Math.PI / 5.2),\n        );\n        ctx.stroke();\n\n        ctx.restore();\n    }\n\n    // ── Layer 3: Bright core ──\n    const coreRadius = r * 0.45;\n    const core = ctx.createRadialGradient(0, 0, 0, 0, 0, coreRadius);\n    core.addColorStop(0, `hsla(0, 0%, 100%, ${dofAlpha * 0.95 + velBoost})`);\n    core.addColorStop(0.5, `hsla(${hue}, ${sat * 0.5}%, ${lum}%, ${dofAlpha * 0.5})`);\n    core.addColorStop(1, `hsla(${hue}, ${sat}%, ${lum - 5}%, 0)`);\n    ctx.fillStyle = core;\n    ctx.beginPath();\n    ctx.arc(0, 0, coreRadius, 0, Math.PI * 2);\n    ctx.fill();\n\n    // ── Layer 4: Sparkle (phase-locked) ──\n    const sparkle = Math.sin(time * 2.4 + f.sparklePhase) * 0.5 + 0.5;\n    if (sparkle > 0.65) {\n        ctx.globalCompositeOperation = \"lighter\";\n        const sparkleAlpha = (sparkle - 0.65) * 2.5 * dofAlpha * 0.35;\n        const sp = ctx.createRadialGradient(0, 0, 0, 0, 0, r * 0.25);\n        sp.addColorStop(0, `hsla(0, 0%, 100%, ${sparkleAlpha})`);\n        sp.addColorStop(1, `hsla(0, 0%, 100%, 0)`);\n        ctx.fillStyle = sp;\n        ctx.beginPath();\n        ctx.arc(0, 0, r * 0.25, 0, Math.PI * 2);\n        ctx.fill();\n    }\n\n    ctx.restore();\n}\n\n/**\n * Draw a soft ambient glow at the mean spawn origin.\n * Fades as the batch ages — creates a \"source of light\" feel.\n */\nfunction drawOriginGlow(\n    ctx: CanvasRenderingContext2D,\n    flakes: Flake[],\n) {\n    if (flakes.length === 0) return;\n\n    // Mean spawn position + average remaining life\n    let sx = 0, sy = 0, totalLife = 0;\n    for (const f of flakes) {\n        sx += f.spawnX;\n        sy += f.spawnY;\n        totalLife += f.life;\n    }\n    sx /= flakes.length;\n    sy /= flakes.length;\n    const avgLife = totalLife / flakes.length;\n\n    const glowAlpha = avgLife * 0.07; // very subtle, max ~7%\n    if (glowAlpha < 0.005) return;\n\n    ctx.save();\n    ctx.globalCompositeOperation = \"screen\";\n    const gr = ctx.createRadialGradient(sx, sy, 0, sx, sy, 35);\n    gr.addColorStop(0, `hsla(215, 40%, 92%, ${glowAlpha})`);\n    gr.addColorStop(0.5, `hsla(215, 30%, 88%, ${glowAlpha * 0.4})`);\n    gr.addColorStop(1, `hsla(215, 20%, 85%, 0)`);\n    ctx.fillStyle = gr;\n    ctx.beginPath();\n    ctx.arc(sx, sy, 35, 0, Math.PI * 2);\n    ctx.fill();\n    ctx.restore();\n}\n\nconst PAD = 120;\nconst FALL_EXTRA = 200;\n\n/**\n * Hard particle ceiling. At fadeSpeed=0.006 a flake lives ~167 frames, so\n * 80 active flakes covers any realistic burst while bounding memory growth\n * and guarding against rapid-click stress.\n */\nconst MAX_FLAKES = 80;\n\n// ─── Interaction-state style lookup tables ───────────────────────────────────\n// Defined at module scope so they are never re-allocated on re-render.\n// These replace direct el.style mutations in mouse handlers, which bypass\n// React's reconciler and are not safe under concurrent rendering.\n\ntype InteractionState = \"rest\" | \"hover\" | \"down\";\n\nconst BUTTON_BG: Record<InteractionState, string> = {\n    rest:  \"linear-gradient(180deg, rgba(235,242,255,0.92) 0%, rgba(200,218,245,0.88) 100%)\",\n    hover: \"linear-gradient(180deg, rgba(242,247,255,0.95) 0%, rgba(210,225,248,0.92) 100%)\",\n    down:  \"linear-gradient(180deg, rgba(220,232,248,0.95) 0%, rgba(190,210,240,0.92) 100%)\",\n};\n\nconst BUTTON_SHADOW: Record<InteractionState, string> = {\n    rest: [\n        \"inset 0 1px 1px rgba(255,255,255,0.7)\",\n        \"inset 0 -1px 2px rgba(160,185,220,0.15)\",\n        \"0 2px 8px rgba(30,60,110,0.12)\",\n        \"0 8px 24px rgba(30,60,120,0.10)\",\n        \"0 0 40px rgba(140,175,225,0.08)\",\n    ].join(\", \"),\n    hover: [\n        \"inset 0 1px 1px rgba(255,255,255,0.8)\",\n        \"inset 0 -1px 2px rgba(160,185,220,0.18)\",\n        \"0 4px 12px rgba(30,60,110,0.15)\",\n        \"0 12px 32px rgba(30,60,120,0.12)\",\n        \"0 0 50px rgba(140,175,225,0.12)\",\n    ].join(\", \"),\n    down: [\n        \"inset 0 1px 2px rgba(160,185,220,0.25)\",\n        \"inset 0 -1px 1px rgba(255,255,255,0.4)\",\n        \"0 1px 4px rgba(30,60,110,0.12)\",\n    ].join(\", \"),\n};\n\nexport interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n    children?: React.ReactNode;\n}\n\nexport const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n    (\n        {\n            children,\n            className = \"\",\n            onClick,\n            onMouseEnter,\n            onMouseLeave,\n            onMouseDown,\n            onMouseUp,\n            style,\n            ...props\n        },\n        ref,\n    ) => {\n        const wrapperRef = useRef<HTMLDivElement>(null);\n        const canvasRef = useRef<HTMLCanvasElement>(null);\n        const animatingRef = useRef(false);\n        const rafRef = useRef<number | null>(null);\n        const flakesRef = useRef<Flake[]>([]);\n        const startTimeRef = useRef(0);\n        // Pending debounce timer for ResizeObserver — cleared in cleanup to prevent leaks\n        const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n        // Checked in handleClick — set once on mount, SSR-safe (window never accessed at render)\n        const reducedMotionRef = useRef(false);\n        // Holds the animate function so it can call itself without a TDZ const-access\n        // error. Using a ref avoids the circular self-reference that useCallback creates.\n        const animateFnRef = useRef<(() => void) | undefined>(undefined);\n\n        // ── Interaction state — drives styles via React re-render, no DOM mutation ──\n        const [istate, setIstate] = useState<InteractionState>(\"rest\");\n\n        // ── Detect prefers-reduced-motion once on mount (SSR-safe) ──\n        useEffect(() => {\n            if (typeof window === \"undefined\") return;\n            const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n            reducedMotionRef.current = mq.matches;\n            const onChange = (e: MediaQueryListEvent) => {\n                reducedMotionRef.current = e.matches;\n            };\n            mq.addEventListener(\"change\", onChange);\n            return () => mq.removeEventListener(\"change\", onChange);\n        }, []);\n\n        // ── Canvas sizing — DPR-aware ──\n        const syncCanvasSize = useCallback(() => {\n            const wrapper = wrapperRef.current;\n            const canvas = canvasRef.current;\n            if (!wrapper || !canvas) return;\n\n            const rect = wrapper.getBoundingClientRect();\n            const dpr = window.devicePixelRatio || 1;\n\n            const w = rect.width + PAD * 2;\n            const h = rect.height + PAD * 2 + FALL_EXTRA;\n\n            canvas.width = w * dpr;\n            canvas.height = h * dpr;\n            canvas.style.width = `${w}px`;\n            canvas.style.height = `${h}px`;\n\n            const ctx = canvas.getContext(\"2d\");\n            if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n        }, []);\n\n        useEffect(() => {\n            syncCanvasSize();\n\n            // ResizeObserver is debounced to prevent layout thrashing on rapid\n            // resize bursts (e.g. window drag). 100 ms is imperceptible to users\n            // while eliminating dozens of redundant canvas re-allocations.\n            const observer = new ResizeObserver(() => {\n                if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current);\n                resizeTimerRef.current = setTimeout(syncCanvasSize, 100);\n            });\n            if (wrapperRef.current) observer.observe(wrapperRef.current);\n\n            return () => {\n                observer.disconnect();\n                // Cancel pending debounce — prevents syncCanvasSize running after unmount\n                if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current);\n                // Cancel any in-flight animation frame — prevents stale canvas writes\n                if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);\n            };\n        }, [syncCanvasSize]);\n\n        const spawnFlakes = useCallback((localX: number, localY: number) => {\n            // Hard cap — ignore spawn if we would exceed MAX_FLAKES.\n            // This protects against rapid-click accumulation with zero per-frame cost.\n            const available = MAX_FLAKES - flakesRef.current.length;\n            if (available <= 0) return;\n\n            const count = Math.min(6 + Math.floor(Math.random() * 4), available); // 6-9\n            const golden = Math.PI * (3 - Math.sqrt(5)); // golden angle ≈ 2.4 rad\n\n            for (let i = 0; i < count; i++) {\n                // Golden-angle spread for elegant, non-uniform distribution\n                const baseAngle = -Math.PI / 2 + golden * i + (Math.random() - 0.5) * 0.4;\n                const speed = 1.0 + Math.random() * 2.5;\n                const depth = Math.random();\n                const size = 2.5 + depth * 5 + Math.random() * 2.5;\n\n                flakesRef.current.push({\n                    x: localX + (Math.random() - 0.5) * 12,\n                    y: localY + (Math.random() - 0.5) * 6,\n                    vx: Math.cos(baseAngle) * speed,\n                    vy: Math.sin(baseAngle) * speed,\n                    size,\n                    opacity: 0.45 + depth * 0.25 + Math.random() * 0.25,\n                    life: 1.0,\n                    rotation: Math.random() * Math.PI * 2,\n                    rotSpeed: (Math.random() - 0.5) * 0.025,\n                    wind: (Math.random() - 0.5) * 0.08,\n                    depth,\n                    sparklePhase: Math.random() * Math.PI * 2,\n                    hueBase: 205 + Math.random() * 20,\n                    driftSeed: Math.random() * 100,\n                    spawnX: localX,\n                    spawnY: localY,\n                    // Pre-compute once — size and depth are immutable,\n                    // so glowRadius never changes for this flake.\n                    glowRadius: size * (1.8 + depth * 1.2),\n                });\n            }\n\n            // Sort by depth once at spawn time — depth is immutable, so the\n            // sorted order is preserved by the in-place compaction in animate().\n            // Eliminates O(n log n) sort from every animation frame.\n            flakesRef.current.sort((a, b) => a.depth - b.depth);\n        }, []);\n\n        // ── Animation loop — stored in a ref to allow clean self-recursion ──\n        // useCallback cannot self-reference without a TDZ const-access error.\n        // Storing the function in a ref and pointing requestAnimationFrame at\n        // the ref avoids that while keeping all animation logic identical.\n        useEffect(() => {\n            function animate() {\n                const canvas = canvasRef.current;\n                if (!canvas) return;\n                const ctx = canvas.getContext(\"2d\");\n                if (!ctx) return;\n\n                const dpr = window.devicePixelRatio || 1;\n                const w = canvas.width / dpr;\n                const h = canvas.height / dpr;\n                ctx.clearRect(0, 0, w, h);\n\n                const time = (performance.now() - startTimeRef.current) / 1000;\n                const gravity = 0.09;\n                const drag = 0.993;\n                const fadeSpeed = 0.006;\n\n                // ── Ambient origin glow (before particles) ──\n                drawOriginGlow(ctx, flakesRef.current);\n\n                // ── In-place compaction — replaces flakesRef.current.filter(…) ──\n                // filter() allocates a new array every frame and triggers GC on the old\n                // one. The two-pointer write pattern mutates the existing array in-place\n                // and truncates the tail, preserving the depth-sorted insertion order.\n                const flakes = flakesRef.current;\n                let write = 0;\n                for (let i = 0; i < flakes.length; i++) {\n                    const f = flakes[i];\n\n                    f.vy += gravity;\n                    f.vx *= drag;\n                    f.vy *= drag;\n\n                    // Organic sinusoidal drift — smooth lateral sway\n                    const drift = Math.sin(time * 1.8 + f.driftSeed) * 0.06;\n                    f.vx += f.wind + drift;\n\n                    f.x += f.vx;\n                    f.y += f.vy;\n                    f.life -= fadeSpeed;\n                    f.rotation += f.rotSpeed;\n\n                    if (f.life > 0) {\n                        drawPremiumFlake(ctx, f, time);\n                        flakes[write++] = f; // compact alive flakes toward the front\n                    }\n                }\n                flakes.length = write; // truncate dead tail — no new array, no GC pressure\n\n                if (flakes.length > 0) {\n                    // Function declaration is hoisted — self-reference is safe here,\n                    // no TDZ issue unlike with a const arrow / useCallback.\n                    rafRef.current = requestAnimationFrame(animate);\n                } else {\n                    animatingRef.current = false;\n                    rafRef.current = null;\n                    ctx.clearRect(0, 0, w, h);\n                }\n            }\n            // Expose to handleClick via a stable ref — no prop/dep needed\n            animateFnRef.current = animate;\n        }, []); // all deps are stable refs — correct to list none\n\n        const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n            const wrapper = wrapperRef.current;\n            if (wrapper) {\n                // Respect prefers-reduced-motion: skip canvas animation entirely.\n                // The click still fires — only the particle effect is gated.\n                if (!reducedMotionRef.current) {\n                    const rect = wrapper.getBoundingClientRect();\n                    const localX = e.clientX - rect.left + PAD;\n                    const localY = e.clientY - rect.top + PAD;\n\n                    spawnFlakes(localX, localY);\n\n                    if (!animatingRef.current) {\n                        animatingRef.current = true;\n                        startTimeRef.current = performance.now();\n                        rafRef.current = requestAnimationFrame(animateFnRef.current!);\n                    }\n                }\n            }\n\n            if (onClick) onClick(e);\n        };\n\n        // Derive button appearance from React state — zero direct DOM mutation.\n        // BUTTON_BG and BUTTON_SHADOW are module-level constants; this object is\n        // cheap to construct and only triggers a re-render when istate changes.\n        const computedButtonStyle: React.CSSProperties = {\n            ...style,\n            background: BUTTON_BG[istate],\n            backdropFilter: \"blur(12px)\",\n            WebkitBackdropFilter: \"blur(12px)\",\n            color: \"#1e2d45\",\n            textShadow: \"0 1px 2px rgba(255,255,255,0.5)\",\n            border: \"1px solid rgba(255,255,255,0.55)\",\n            boxShadow: BUTTON_SHADOW[istate],\n        };\n\n        return (\n            <div ref={wrapperRef} className=\"relative inline-block\">\n                <canvas\n                    ref={canvasRef}\n                    aria-hidden=\"true\"\n                    className=\"absolute pointer-events-none\"\n                    style={{\n                        top: `-${PAD}px`,\n                        left: `-${PAD}px`,\n                        zIndex: 20,\n                    }}\n                />\n\n                <button\n                    ref={ref}\n                    type=\"button\"\n                    onClick={handleClick}\n                    className={`\n                    relative py-2 px-[24px] rounded-full scale-200\n                    font-semibold text-sm tracking-wider\n                    transition-all duration-200 ease-out\n                    cursor-pointer\n                    hover:scale-[2.02]\n                    active:scale-[1.96]\n                    ${className}\n                `}\n                    style={computedButtonStyle}\n                    onMouseEnter={(e) => {\n                        setIstate(\"hover\");\n                        if (onMouseEnter) onMouseEnter(e);\n                    }}\n                    onMouseLeave={(e) => {\n                        setIstate(\"rest\");\n                        if (onMouseLeave) onMouseLeave(e);\n                    }}\n                    onMouseDown={(e) => {\n                        setIstate(\"down\");\n                        if (onMouseDown) onMouseDown(e);\n                    }}\n                    onMouseUp={(e) => {\n                        // Mouse is still over the button after release → hover\n                        setIstate(\"hover\");\n                        if (onMouseUp) onMouseUp(e);\n                    }}\n                    {...props}\n                >\n\n                    {/* Snow cap with shadow for 3D depth */}\n                    <svg\n                        aria-hidden=\"true\"\n                        className=\"absolute top-0 left-1/2 -translate-x-1/2 -translate-y-[24%] w-[102%] h-8.5 cursor-pointer\"\n                        preserveAspectRatio=\"none\"\n                        viewBox=\"0 -6 274 82\"\n                        xmlns=\"http://www.w3.org/2000/svg\"\n                        overflow=\"visible\">\n                        <defs>\n                            <filter id=\"snow-shadow\" x=\"-10%\" y=\"-10%\" width=\"120%\" height=\"140%\">\n                                <feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"2\" />\n                                <feOffset dx=\"0\" dy=\"2\" />\n                                <feComponentTransfer>\n                                    <feFuncA type=\"linear\" slope=\"0.15\" />\n                                </feComponentTransfer>\n                                <feMerge>\n                                    <feMergeNode />\n                                    <feMergeNode in=\"SourceGraphic\" />\n                                </feMerge>\n                            </filter>\n                        </defs>\n                        {/* Snow body */}\n                        <path\n                            filter=\"url(#snow-shadow)\"\n                            d=\"M 142.72 29.275 \n                            C 136.135 32.634 109.686 32.081 102.178 29.275 \n                            C 72.905 21.506 55.541 58.255 37.85 44.307 \n                            C 20.16 30.359 12.719 41.265 0 63.999 \n                            C 0 55.794 3.718 22.662 24.955 6.919 \n                            C 24.955 6.919 42.799 -5.381 72.354 2.819 \n                            C 101.91 11.02 179.852 15.38 215.168 8.205 \n                            C 259.601 -0.82 274 35.282 274 64 \n                            C 260.486 45.811 266.099 75.406 254.199 49.197 \n                            C 254.068 49.366 248.939 40.115 248.685 40.242 \n                            C 230.318 26.763 223.294 59.228 204.739 49.197 \n                            C 183.96 40.242 162.04 19.424 142.72 29.275 Z\"\n                            fill=\"#ffffff\"\n                        />\n                    </svg>\n                    {children}\n                </button>\n            </div>\n        );\n    });\n\nButton.displayName = \"Button\";\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}