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";