{ "name": "pixel-canvas", "type": "registry:ui", "dependencies": [], "registryDependencies": [], "description": "An interactive pixel grid with smooth trailing effects that lights up on hover and decays over time.", "files": [ { "path": "components/ui/pixel-canvas.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport React, { useEffect, useRef, useCallback } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface PixelCanvasProps extends React.HTMLAttributes {\n /** Size of each pixel cell in pixels */\n gap?: number;\n /** Speed of the trailing decay (higher = faster fade) */\n speed?: number;\n /** Array of colors for pixels - will interpolate through them as trail fades */\n colors?: string[];\n /** Disable mouse tracking */\n noFocus?: boolean;\n /** Variant style */\n variant?: \"default\" | \"trail\" | \"glow\";\n}\n\ninterface Pixel {\n x: number;\n y: number;\n size: number;\n intensity: number;\n targetIntensity: number;\n colorPhase: number;\n}\n\n// Helper to interpolate between two hex colors\nfunction lerpColor(color1: string, color2: string, t: number): string {\n const c1 = hexToRgb(color1);\n const c2 = hexToRgb(color2);\n if (!c1 || !c2) return color1;\n\n const r = Math.round(c1.r + (c2.r - c1.r) * t);\n const g = Math.round(c1.g + (c2.g - c1.g) * t);\n const b = Math.round(c1.b + (c2.b - c1.b) * t);\n\n return `rgb(${r}, ${g}, ${b})`;\n}\n\nfunction hexToRgb(hex: string): { r: number; g: number; b: number } | null {\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1]!, 16),\n g: parseInt(result[2]!, 16),\n b: parseInt(result[3]!, 16),\n }\n : null;\n}\n\nexport function PixelCanvas({\n className,\n gap = 6,\n speed = 0.02,\n colors = [\"#e879f9\", \"#a78bfa\", \"#38bdf8\", \"#22d3ee\"],\n noFocus = false,\n variant = \"default\",\n ...props\n}: PixelCanvasProps) {\n const canvasRef = useRef(null);\n const containerRef = useRef(null);\n const pixelsRef = useRef([]);\n const mouseRef = useRef({ x: -1000, y: -1000 });\n const animationRef = useRef(0);\n const lastTimeRef = useRef(0);\n\n const getColorFromIntensity = useCallback((intensity: number, phase: number) => {\n if (colors.length === 0) return \"#ffffff\";\n if (colors.length === 1) return colors[0]!;\n\n // Use phase + intensity to create a shifting color effect\n const t = (phase + intensity) % 1;\n const index = Math.floor(t * (colors.length - 1));\n const nextIndex = Math.min(index + 1, colors.length - 1);\n const localT = (t * (colors.length - 1)) % 1;\n\n const color1 = colors[index];\n const color2 = colors[nextIndex];\n\n if (!color1) return \"#ffffff\";\n if (!color2) return color1!;\n\n return lerpColor(color1, color2, localT);\n }, [colors]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n const container = containerRef.current;\n if (!canvas || !container) return;\n\n const ctx = canvas.getContext(\"2d\", { alpha: true });\n if (!ctx) return;\n\n let cols = 0;\n let rows = 0;\n const pixelSize = Math.max(gap, 4);\n\n const initPixels = () => {\n const rect = container.getBoundingClientRect();\n const dpr = window.devicePixelRatio || 1;\n\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n canvas.style.width = `${rect.width}px`;\n canvas.style.height = `${rect.height}px`;\n ctx.scale(dpr, dpr);\n\n cols = Math.ceil(rect.width / pixelSize);\n rows = Math.ceil(rect.height / pixelSize);\n\n const newPixels: Pixel[][] = [];\n for (let i = 0; i < cols; i++) {\n const row: Pixel[] = [];\n for (let j = 0; j < rows; j++) {\n // Preserve existing intensity if pixel exists\n const existing = pixelsRef.current[i]?.[j];\n row.push({\n x: i * pixelSize,\n y: j * pixelSize,\n size: pixelSize - 1,\n intensity: existing?.intensity ?? 0,\n targetIntensity: 0,\n colorPhase: Math.random(), // Random starting phase for color variety\n });\n }\n newPixels.push(row);\n }\n pixelsRef.current = newPixels;\n };\n\n const draw = (timestamp: number) => {\n const deltaTime = timestamp - lastTimeRef.current;\n lastTimeRef.current = timestamp;\n\n const rect = container.getBoundingClientRect();\n ctx.clearRect(0, 0, rect.width, rect.height);\n\n const { x: mouseX, y: mouseY } = mouseRef.current;\n const pixels = pixelsRef.current;\n\n // Influence radius based on variant\n const radius = variant === \"glow\" ? 120 : 80;\n const glowPasses = variant === \"glow\" ? 2 : 1;\n\n // Update pixel states\n for (let i = 0; i < cols; i++) {\n const col = pixels[i];\n if (!col) continue;\n\n for (let j = 0; j < rows; j++) {\n const pixel = col[j];\n if (!pixel) continue;\n\n // Calculate distance from mouse\n const centerX = pixel.x + pixel.size / 2;\n const centerY = pixel.y + pixel.size / 2;\n const dx = mouseX - centerX;\n const dy = mouseY - centerY;\n const distance = Math.sqrt(dx * dx + dy * dy);\n\n // Set target intensity based on distance\n if (distance < radius) {\n const falloff = 1 - (distance / radius);\n // Smooth falloff curve\n pixel.targetIntensity = Math.pow(falloff, 1.5);\n } else {\n pixel.targetIntensity = 0;\n }\n\n // Smooth interpolation towards target\n const lerpSpeed = pixel.targetIntensity > pixel.intensity\n ? 0.3 // Quick light up\n : speed; // Slow decay for trailing\n\n pixel.intensity += (pixel.targetIntensity - pixel.intensity) * lerpSpeed;\n\n // Shift color phase slowly for shimmer effect\n pixel.colorPhase = (pixel.colorPhase + 0.001 * (deltaTime / 16)) % 1;\n\n // Only draw if visible\n if (pixel.intensity > 0.01) {\n const color = getColorFromIntensity(pixel.intensity, pixel.colorPhase);\n\n // Glow effect: draw larger, blurred version first\n if (variant === \"glow\" && pixel.intensity > 0.2) {\n for (let g = glowPasses; g > 0; g--) {\n const glowSize = pixel.size + g * 4;\n const glowOffset = (glowSize - pixel.size) / 2;\n ctx.globalAlpha = pixel.intensity * 0.15 / g;\n ctx.fillStyle = color;\n ctx.fillRect(\n pixel.x - glowOffset,\n pixel.y - glowOffset,\n glowSize,\n glowSize\n );\n }\n }\n\n // Main pixel\n ctx.globalAlpha = pixel.intensity * 0.9;\n ctx.fillStyle = color;\n\n if (variant === \"trail\") {\n // Rounded pixels for trail variant\n const cornerRadius = pixel.size * 0.3;\n ctx.beginPath();\n ctx.roundRect(pixel.x, pixel.y, pixel.size, pixel.size, cornerRadius);\n ctx.fill();\n } else {\n ctx.fillRect(pixel.x, pixel.y, pixel.size, pixel.size);\n }\n }\n }\n }\n\n ctx.globalAlpha = 1;\n animationRef.current = requestAnimationFrame(draw);\n };\n\n const onMouseMove = (e: MouseEvent) => {\n const rect = canvas.getBoundingClientRect();\n mouseRef.current = {\n x: e.clientX - rect.left,\n y: e.clientY - rect.top,\n };\n };\n\n const onMouseLeave = () => {\n mouseRef.current = { x: -1000, y: -1000 };\n };\n\n const onTouchMove = (e: TouchEvent) => {\n if (e.touches.length > 0) {\n const touch = e.touches[0];\n if (touch) {\n const rect = canvas.getBoundingClientRect();\n mouseRef.current = {\n x: touch.clientX - rect.left,\n y: touch.clientY - rect.top,\n };\n }\n }\n };\n\n const onTouchEnd = () => {\n mouseRef.current = { x: -1000, y: -1000 };\n };\n\n // Initialize\n initPixels();\n lastTimeRef.current = performance.now();\n animationRef.current = requestAnimationFrame(draw);\n\n // Event listeners\n window.addEventListener(\"resize\", initPixels);\n if (!noFocus) {\n container.addEventListener(\"mousemove\", onMouseMove);\n container.addEventListener(\"mouseleave\", onMouseLeave);\n container.addEventListener(\"touchmove\", onTouchMove, { passive: true });\n container.addEventListener(\"touchend\", onTouchEnd);\n }\n\n return () => {\n cancelAnimationFrame(animationRef.current);\n window.removeEventListener(\"resize\", initPixels);\n container.removeEventListener(\"mousemove\", onMouseMove);\n container.removeEventListener(\"mouseleave\", onMouseLeave);\n container.removeEventListener(\"touchmove\", onTouchMove);\n container.removeEventListener(\"touchend\", onTouchEnd);\n };\n }, [gap, speed, noFocus, variant, getColorFromIntensity]);\n\n return (\n \n \n \n );\n}\n" } ] }