shadimage
knot-ui
componentbuttonsource: ok · hit

Premium Snow Button

A cinematic button with realistic snowflake particle effects rendered on a canvas.

npx shadcn add "https://knot-ui.trupal.me/r/button.json"
free

Where it goes

blog-list

Visual style

particle

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/15/2026, 4:04:22 AM

components/library/button.tsx
"use client";

import React, { useRef, useCallback, useEffect, useState } from "react";

interface Flake {
    x: number;
    y: number;
    vx: number;
    vy: number;
    size: number;
    opacity: number;
    life: number;
    rotation: number;
    rotSpeed: number;
    wind: number;
    // Premium rendering properties
    depth: number;        // 0 = far/sharp, 1 = near/soft — immutable after spawn
    sparklePhase: number; // fixed phase for subtle sparkle
    hueBase: number;      // base hue (205-225), shifts warmer as life fades
    driftSeed: number;    // unique seed for sinusoidal lateral drift
    spawnX: number;       // origin x for ambient glow
    spawnY: number;       // origin y for ambient glow
    // Pre-computed at spawn — depth and size never change, so this is immutable.
    // Eliminates a multiply + multiply per flake per frame from the hot path.
    glowRadius: number;
}

/**
 * Draw a cinematic snowflake with light-scatter glow,
 * color temperature shift, velocity-based luminance,
 * crystal arms, and subtle sparkle.
 */
function drawPremiumFlake(
    ctx: CanvasRenderingContext2D,
    f: Flake,
    time: number,
) {
    const alpha = f.opacity * f.life;
    if (alpha <= 0.01) return;

    const r = f.size;

    // ── Velocity-based luminance boost ──
    const speed = Math.sqrt(f.vx * f.vx + f.vy * f.vy);
    const velBoost = Math.min(speed * 0.08, 0.15);

    // ── Color temperature shift ──
    // Fresh → warm white  |  Aging → icy blue
    const age = 1 - f.life;
    const hue = f.hueBase + age * 10;   // 205-235 range
    const sat = 40 + age * 25;          // 40% → 65%
    const lum = 92 - age * 7;           // 92% → 85%

    // ── Depth-of-field opacity (soft, not dramatic) ──
    const dofAlpha = alpha * (0.75 + f.depth * 0.25);

    ctx.save();
    ctx.translate(f.x, f.y);

    // ── Layer 1: Glow halo ──
    // "lighter" (additive) pops on both dark and light backgrounds.
    // glowRadius is pre-computed at spawn (f.glowRadius) — no per-frame math.
    // Guard: skip the two gradient allocations entirely when the peak alpha is
    // below the perception threshold, a common case as flakes age out.
    const glowPeak = dofAlpha * 0.18 + velBoost * 0.05;
    if (glowPeak > 0.008) {
        ctx.globalCompositeOperation = "lighter";
        const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, f.glowRadius);
        glow.addColorStop(0, `hsla(${hue}, ${sat}%, ${lum}%, ${glowPeak})`);
        glow.addColorStop(0.4, `hsla(${hue}, ${sat * 0.7}%, ${lum - 3}%, ${glowPeak * 0.3})`);
        glow.addColorStop(1, `hsla(${hue}, ${sat * 0.5}%, ${lum - 6}%, 0)`);
        ctx.fillStyle = glow;
        ctx.beginPath();
        ctx.arc(0, 0, f.glowRadius, 0, Math.PI * 2);
        ctx.fill();
    }

    // ── Layer 2: Crystal body (6-armed) ──
    ctx.globalCompositeOperation = "source-over";
    ctx.rotate(f.rotation);

    const arms = 6;
    const armLen = r;
    const branchLen = r * 0.3;
    const lineW = Math.max(0.6, r * 0.14);

    for (let i = 0; i < arms; i++) {
        const angle = (Math.PI * 2 * i) / arms;
        ctx.save();
        ctx.rotate(angle);

        // Main arm: pure white root → icy blue tip
        const armGrad = ctx.createLinearGradient(0, 0, armLen, 0);
        armGrad.addColorStop(0, `hsla(0, 0%, 100%, ${dofAlpha * 0.9 + velBoost})`);
        armGrad.addColorStop(1, `hsla(${hue}, ${sat}%, ${lum}%, ${dofAlpha * 0.5})`);

        ctx.strokeStyle = armGrad;
        ctx.lineWidth = lineW;
        ctx.lineCap = "round";
        ctx.beginPath();
        ctx.moveTo(r * 0.12, 0);
        ctx.lineTo(armLen, 0);
        ctx.stroke();

        // Branches — asymmetric for naturalism
        ctx.strokeStyle = `hsla(0, 0%, 100%, ${dofAlpha * 0.65})`;
        ctx.lineWidth = lineW * 0.75;

        const branchAnchor = armLen * 0.55;
        ctx.beginPath();
        ctx.moveTo(branchAnchor, 0);
        ctx.lineTo(
            branchAnchor + branchLen * Math.cos(-Math.PI / 4.8),
            branchLen * Math.sin(-Math.PI / 4.8),
        );
        ctx.stroke();

        ctx.beginPath();
        ctx.moveTo(branchAnchor, 0);
        ctx.lineTo(
            branchAnchor + branchLen * Math.cos(Math.PI / 5.2),
            branchLen * Math.sin(Math.PI / 5.2),
        );
        ctx.stroke();

        ctx.restore();
    }

    // ── Layer 3: Bright core ──
    const coreRadius = r * 0.45;
    const core = ctx.createRadialGradient(0, 0, 0, 0, 0, coreRadius);
    core.addColorStop(0, `hsla(0, 0%, 100%, ${dofAlpha * 0.95 + velBoost})`);
    core.addColorStop(0.5, `hsla(${hue}, ${sat * 0.5}%, ${lum}%, ${dofAlpha * 0.5})`);
    core.addColorStop(1, `hsla(${hue}, ${sat}%, ${lum - 5}%, 0)`);
    ctx.fillStyle = core;
    ctx.beginPath();
    ctx.arc(0, 0, coreRadius, 0, Math.PI * 2);
    ctx.fill();

    // ── Layer 4: Sparkle (phase-locked) ──
    const sparkle = Math.sin(time * 2.4 + f.sparklePhase) * 0.5 + 0.5;
    if (sparkle > 0.65) {
        ctx.globalCompositeOperation = "lighter";
        const sparkleAlpha = (sparkle - 0.65) * 2.5 * dofAlpha * 0.35;
        const sp = ctx.createRadialGradient(0, 0, 0, 0, 0, r * 0.25);
        sp.addColorStop(0, `hsla(0, 0%, 100%, ${sparkleAlpha})`);
        sp.addColorStop(1, `hsla(0, 0%, 100%, 0)`);
        ctx.fillStyle = sp;
        ctx.beginPath();
        ctx.arc(0, 0, r * 0.25, 0, Math.PI * 2);
        ctx.fill();
    }

    ctx.restore();
}

/**
 * Draw a soft ambient glow at the mean spawn origin.
 * Fades as the batch ages — creates a "source of light" feel.
 */
function drawOriginGlow(
    ctx: CanvasRenderingContext2D,
    flakes: Flake[],
) {
    if (flakes.length === 0) return;

    // Mean spawn position + average remaining life
    let sx = 0, sy = 0, totalLife = 0;
    for (const f of flakes) {
        sx += f.spawnX;
        sy += f.spawnY;
        totalLife += f.life;
    }
    sx /= flakes.length;
    sy /= flakes.length;
    const avgLife = totalLife / flakes.length;

    const glowAlpha = avgLife * 0.07; // very subtle, max ~7%
    if (glowAlpha < 0.005) return;

    ctx.save();
    ctx.globalCompositeOperation = "screen";
    const gr = ctx.createRadialGradient(sx, sy, 0, sx, sy, 35);
    gr.addColorStop(0, `hsla(215, 40%, 92%, ${glowAlpha})`);
    gr.addColorStop(0.5, `hsla(215, 30%, 88%, ${glowAlpha * 0.4})`);
    gr.addColorStop(1, `hsla(215, 20%, 85%, 0)`);
    ctx.fillStyle = gr;
    ctx.beginPath();
    ctx.arc(sx, sy, 35, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
}

const PAD = 120;
const FALL_EXTRA = 200;

/**
 * Hard particle ceiling. At fadeSpeed=0.006 a flake lives ~167 frames, so
 * 80 active flakes covers any realistic burst while bounding memory growth
 * and guarding against rapid-click stress.
 */
const MAX_FLAKES = 80;

// ─── Interaction-state style lookup tables ───────────────────────────────────
// Defined at module scope so they are never re-allocated on re-render.
// These replace direct el.style mutations in mouse handlers, which bypass
// React's reconciler and are not safe under concurrent rendering.

type InteractionState = "rest" | "hover" | "down";

const BUTTON_BG: Record<InteractionState, string> = {
    rest:  "linear-gradient(180deg, rgba(235,242,255,0.92) 0%, rgba(200,218,245,0.88) 100%)",
    hover: "linear-gradient(180deg, rgba(242,247,255,0.95) 0%, rgba(210,225,248,0.92) 100%)",
    down:  "linear-gradient(180deg, rgba(220,232,248,0.95) 0%, rgba(190,210,240,0.92) 100%)",
};

const BUTTON_SHADOW: Record<InteractionState, string> = {
    rest: [
        "inset 0 1px 1px rgba(255,255,255,0.7)",
        "inset 0 -1px 2px rgba(160,185,220,0.15)",
        "0 2px 8px rgba(30,60,110,0.12)",
        "0 8px 24px rgba(30,60,120,0.10)",
        "0 0 40px rgba(140,175,225,0.08)",
    ].join(", "),
    hover: [
        "inset 0 1px 1px rgba(255,255,255,0.8)",
        "inset 0 -1px 2px rgba(160,185,220,0.18)",
        "0 4px 12px rgba(30,60,110,0.15)",
        "0 12px 32px rgba(30,60,120,0.12)",
        "0 0 50px rgba(140,175,225,0.12)",
    ].join(", "),
    down: [
        "inset 0 1px 2px rgba(160,185,220,0.25)",
        "inset 0 -1px 1px rgba(255,255,255,0.4)",
        "0 1px 4px rgba(30,60,110,0.12)",
    ].join(", "),
};

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
    children?: React.ReactNode;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
    (
        {
            children,
            className = "",
            onClick,
            onMouseEnter,
            onMouseLeave,
            onMouseDown,
            onMouseUp,
            style,
            ...props
        },
        ref,
    ) => {
        const wrapperRef = useRef<HTMLDivElement>(null);
        const canvasRef = useRef<HTMLCanvasElement>(null);
        const animatingRef = useRef(false);
        const rafRef = useRef<number | null>(null);
        const flakesRef = useRef<Flake[]>([]);
        const startTimeRef = useRef(0);
        // Pending debounce timer for ResizeObserver — cleared in cleanup to prevent leaks
        const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
        // Checked in handleClick — set once on mount, SSR-safe (window never accessed at render)
        const reducedMotionRef = useRef(false);
        // Holds the animate function so it can call itself without a TDZ const-access
        // error. Using a ref avoids the circular self-reference that useCallback creates.
        const animateFnRef = useRef<(() => void) | undefined>(undefined);

        // ── Interaction state — drives styles via React re-render, no DOM mutation ──
        const [istate, setIstate] = useState<InteractionState>("rest");

        // ── Detect prefers-reduced-motion once on mount (SSR-safe) ──
        useEffect(() => {
            if (typeof window === "undefined") return;
            const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
            reducedMotionRef.current = mq.matches;
            const onChange = (e: MediaQueryListEvent) => {
                reducedMotionRef.current = e.matches;
            };
            mq.addEventListener("change", onChange);
            return () => mq.removeEventListener("change", onChange);
        }, []);

        // ── Canvas sizing — DPR-aware ──
        const syncCanvasSize = useCallback(() => {
            const wrapper = wrapperRef.current;
            const canvas = canvasRef.current;
            if (!wrapper || !canvas) return;

            const rect = wrapper.getBoundingClientRect();
            const dpr = window.devicePixelRatio || 1;

            const w = rect.width + PAD * 2;
            const h = rect.height + PAD * 2 + FALL_EXTRA;

            canvas.width = w * dpr;
            canvas.height = h * dpr;
            canvas.style.width = `${w}px`;
            canvas.style.height = `${h}px`;

            const ctx = canvas.getContext("2d");
            if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        }, []);

        useEffect(() => {
            syncCanvasSize();

            // ResizeObserver is debounced to prevent layout thrashing on rapid
            // resize bursts (e.g. window drag). 100 ms is imperceptible to users
            // while eliminating dozens of redundant canvas re-allocations.
            const observer = new ResizeObserver(() => {
                if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current);
                resizeTimerRef.current = setTimeout(syncCanvasSize, 100);
            });
            if (wrapperRef.current) observer.observe(wrapperRef.current);

            return () => {
                observer.disconnect();
                // Cancel pending debounce — prevents syncCanvasSize running after unmount
                if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current);
                // Cancel any in-flight animation frame — prevents stale canvas writes
                if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
            };
        }, [syncCanvasSize]);

        const spawnFlakes = useCallback((localX: number, localY: number) => {
            // Hard cap — ignore spawn if we would exceed MAX_FLAKES.
            // This protects against rapid-click accumulation with zero per-frame cost.
            const available = MAX_FLAKES - flakesRef.current.length;
            if (available <= 0) return;

            const count = Math.min(6 + Math.floor(Math.random() * 4), available); // 6-9
            const golden = Math.PI * (3 - Math.sqrt(5)); // golden angle ≈ 2.4 rad

            for (let i = 0; i < count; i++) {
                // Golden-angle spread for elegant, non-uniform distribution
                const baseAngle = -Math.PI / 2 + golden * i + (Math.random() - 0.5) * 0.4;
                const speed = 1.0 + Math.random() * 2.5;
                const depth = Math.random();
                const size = 2.5 + depth * 5 + Math.random() * 2.5;

                flakesRef.current.push({
                    x: localX + (Math.random() - 0.5) * 12,
                    y: localY + (Math.random() - 0.5) * 6,
                    vx: Math.cos(baseAngle) * speed,
                    vy: Math.sin(baseAngle) * speed,
                    size,
                    opacity: 0.45 + depth * 0.25 + Math.random() * 0.25,
                    life: 1.0,
                    rotation: Math.random() * Math.PI * 2,
                    rotSpeed: (Math.random() - 0.5) * 0.025,
                    wind: (Math.random() - 0.5) * 0.08,
                    depth,
                    sparklePhase: Math.random() * Math.PI * 2,
                    hueBase: 205 + Math.random() * 20,
                    driftSeed: Math.random() * 100,
                    spawnX: localX,
                    spawnY: localY,
                    // Pre-compute once — size and depth are immutable,
                    // so glowRadius never changes for this flake.
                    glowRadius: size * (1.8 + depth * 1.2),
                });
            }

            // Sort by depth once at spawn time — depth is immutable, so the
            // sorted order is preserved by the in-place compaction in animate().
            // Eliminates O(n log n) sort from every animation frame.
            flakesRef.current.sort((a, b) => a.depth - b.depth);
        }, []);

        // ── Animation loop — stored in a ref to allow clean self-recursion ──
        // useCallback cannot self-reference without a TDZ const-access error.
        // Storing the function in a ref and pointing requestAnimationFrame at
        // the ref avoids that while keeping all animation logic identical.
        useEffect(() => {
            function animate() {
                const canvas = canvasRef.current;
                if (!canvas) return;
                const ctx = canvas.getContext("2d");
                if (!ctx) return;

                const dpr = window.devicePixelRatio || 1;
                const w = canvas.width / dpr;
                const h = canvas.height / dpr;
                ctx.clearRect(0, 0, w, h);

                const time = (performance.now() - startTimeRef.current) / 1000;
                const gravity = 0.09;
                const drag = 0.993;
                const fadeSpeed = 0.006;

                // ── Ambient origin glow (before particles) ──
                drawOriginGlow(ctx, flakesRef.current);

                // ── In-place compaction — replaces flakesRef.current.filter(…) ──
                // filter() allocates a new array every frame and triggers GC on the old
                // one. The two-pointer write pattern mutates the existing array in-place
                // and truncates the tail, preserving the depth-sorted insertion order.
                const flakes = flakesRef.current;
                let write = 0;
                for (let i = 0; i < flakes.length; i++) {
                    const f = flakes[i];

                    f.vy += gravity;
                    f.vx *= drag;
                    f.vy *= drag;

                    // Organic sinusoidal drift — smooth lateral sway
                    const drift = Math.sin(time * 1.8 + f.driftSeed) * 0.06;
                    f.vx += f.wind + drift;

                    f.x += f.vx;
                    f.y += f.vy;
                    f.life -= fadeSpeed;
                    f.rotation += f.rotSpeed;

                    if (f.life > 0) {
                        drawPremiumFlake(ctx, f, time);
                        flakes[write++] = f; // compact alive flakes toward the front
                    }
                }
                flakes.length = write; // truncate dead tail — no new array, no GC pressure

                if (flakes.length > 0) {
                    // Function declaration is hoisted — self-reference is safe here,
                    // no TDZ issue unlike with a const arrow / useCallback.
                    rafRef.current = requestAnimationFrame(animate);
                } else {
                    animatingRef.current = false;
                    rafRef.current = null;
                    ctx.clearRect(0, 0, w, h);
                }
            }
            // Expose to handleClick via a stable ref — no prop/dep needed
            animateFnRef.current = animate;
        }, []); // all deps are stable refs — correct to list none

        const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
            const wrapper = wrapperRef.current;
            if (wrapper) {
                // Respect prefers-reduced-motion: skip canvas animation entirely.
                // The click still fires — only the particle effect is gated.
                if (!reducedMotionRef.current) {
                    const rect = wrapper.getBoundingClientRect();
                    const localX = e.clientX - rect.left + PAD;
                    const localY = e.clientY - rect.top + PAD;

                    spawnFlakes(localX, localY);

                    if (!animatingRef.current) {
                        animatingRef.current = true;
                        startTimeRef.current = performance.now();
                        rafRef.current = requestAnimationFrame(animateFnRef.current!);
                    }
                }
            }

            if (onClick) onClick(e);
        };

        // Derive button appearance from React state — zero direct DOM mutation.
        // BUTTON_BG and BUTTON_SHADOW are module-level constants; this object is
        // cheap to construct and only triggers a re-render when istate changes.
        const computedButtonStyle: React.CSSProperties = {
            ...style,
            background: BUTTON_BG[istate],
            backdropFilter: "blur(12px)",
            WebkitBackdropFilter: "blur(12px)",
            color: "#1e2d45",
            textShadow: "0 1px 2px rgba(255,255,255,0.5)",
            border: "1px solid rgba(255,255,255,0.55)",
            boxShadow: BUTTON_SHADOW[istate],
        };

        return (
            <div ref={wrapperRef} className="relative inline-block">
                <canvas
                    ref={canvasRef}
                    aria-hidden="true"
                    className="absolute pointer-events-none"
                    style={{
                        top: `-${PAD}px`,
                        left: `-${PAD}px`,
                        zIndex: 20,
                    }}
                />

                <button
                    ref={ref}
                    type="button"
                    onClick={handleClick}
                    className={`
                    relative py-2 px-[24px] rounded-full scale-200
                    font-semibold text-sm tracking-wider
                    transition-all duration-200 ease-out
                    cursor-pointer
                    hover:scale-[2.02]
                    active:scale-[1.96]
                    ${className}
                `}
                    style={computedButtonStyle}
                    onMouseEnter={(e) => {
                        setIstate("hover");
                        if (onMouseEnter) onMouseEnter(e);
                    }}
                    onMouseLeave={(e) => {
                        setIstate("rest");
                        if (onMouseLeave) onMouseLeave(e);
                    }}
                    onMouseDown={(e) => {
                        setIstate("down");
                        if (onMouseDown) onMouseDown(e);
                    }}
                    onMouseUp={(e) => {
                        // Mouse is still over the button after release → hover
                        setIstate("hover");
                        if (onMouseUp) onMouseUp(e);
                    }}
                    {...props}
                >

                    {/* Snow cap with shadow for 3D depth */}
                    <svg
                        aria-hidden="true"
                        className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-[24%] w-[102%] h-8.5 cursor-pointer"
                        preserveAspectRatio="none"
                        viewBox="0 -6 274 82"
                        xmlns="http://www.w3.org/2000/svg"
                        overflow="visible">
                        <defs>
                            <filter id="snow-shadow" x="-10%" y="-10%" width="120%" height="140%">
                                <feGaussianBlur in="SourceAlpha" stdDeviation="2" />
                                <feOffset dx="0" dy="2" />
                                <feComponentTransfer>
                                    <feFuncA type="linear" slope="0.15" />
                                </feComponentTransfer>
                                <feMerge>
                                    <feMergeNode />
                                    <feMergeNode in="SourceGraphic" />
                                </feMerge>
                            </filter>
                        </defs>
                        {/* Snow body */}
                        <path
                            filter="url(#snow-shadow)"
                            d="M 142.72 29.275 
                            C 136.135 32.634 109.686 32.081 102.178 29.275 
                            C 72.905 21.506 55.541 58.255 37.85 44.307 
                            C 20.16 30.359 12.719 41.265 0 63.999 
                            C 0 55.794 3.718 22.662 24.955 6.919 
                            C 24.955 6.919 42.799 -5.381 72.354 2.819 
                            C 101.91 11.02 179.852 15.38 215.168 8.205 
                            C 259.601 -0.82 274 35.282 274 64 
                            C 260.486 45.811 266.099 75.406 254.199 49.197 
                            C 254.068 49.366 248.939 40.115 248.685 40.242 
                            C 230.318 26.763 223.294 59.228 204.739 49.197 
                            C 183.96 40.242 162.04 19.424 142.72 29.275 Z"
                            fill="#ffffff"
                        />
                    </svg>
                    {children}
                </button>
            </div>
        );
    });

Button.displayName = "Button";

More from knot-ui

Browse all components in this library →