{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "hero-mesh", "title": "Hero Mesh", "description": "Interactive mesh grid background that responds to cursor movement", "files": [ { "path": "components/animation/hero-mesh.tsx", "content": "\"use client\";\n\nimport { useEffect, useRef, useCallback } from \"react\";\n\ninterface Node {\n baseX: number;\n baseY: number;\n x: number;\n y: number;\n}\n\nconst GRID_SPACING = 40;\nconst DOT_RADIUS = 1.2;\nconst DOT_OPACITY = 0.15;\nconst LINE_OPACITY = 0.04;\nconst MOUSE_RADIUS = 120;\nconst MOUSE_FORCE = 12;\nconst SINE_AMPLITUDE = 1.5;\nconst SINE_SPEED = 0.0004;\n\nexport function HeroMesh({ className }: { className?: string }) {\n const canvasRef = useRef(null);\n const mouseRef = useRef({ x: -9999, y: -9999 });\n const nodesRef = useRef([]);\n const colsRef = useRef(0);\n const animRef = useRef(0);\n const visibleRef = useRef(true);\n const reducedMotionRef = useRef(false);\n const sizeRef = useRef({ w: 0, h: 0 });\n\n const buildGrid = useCallback((width: number, height: number) => {\n const nodes: Node[] = [];\n const cols = Math.ceil(width / GRID_SPACING) + 2;\n const rows = Math.ceil(height / GRID_SPACING) + 2;\n colsRef.current = cols + 1; // account for c starting at -1\n for (let r = -1; r < rows; r++) {\n for (let c = -1; c < cols; c++) {\n const x = c * GRID_SPACING;\n const y = r * GRID_SPACING;\n nodes.push({ baseX: x, baseY: y, x, y });\n }\n }\n nodesRef.current = nodes;\n }, []);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n const mql = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n reducedMotionRef.current = mql.matches;\n const motionHandler = (e: MediaQueryListEvent) => {\n reducedMotionRef.current = e.matches;\n if (e.matches && animRef.current) {\n cancelAnimationFrame(animRef.current);\n animRef.current = 0;\n }\n };\n mql.addEventListener(\"change\", motionHandler);\n\n function resize() {\n if (!canvas) return;\n const parent = canvas.parentElement;\n if (!parent) return;\n const dpr = window.devicePixelRatio || 1;\n const w = parent.clientWidth;\n const h = parent.clientHeight;\n canvas.width = w * dpr;\n canvas.height = h * dpr;\n canvas.style.width = `${w}px`;\n canvas.style.height = `${h}px`;\n ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);\n sizeRef.current = { w, h };\n buildGrid(w, h);\n }\n\n resize();\n let resizeTimer: ReturnType;\n function debouncedResize() {\n clearTimeout(resizeTimer);\n resizeTimer = setTimeout(resize, 150);\n }\n window.addEventListener(\"resize\", debouncedResize);\n\n function onMouseMove(e: MouseEvent) {\n if (!canvas) return;\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n // Only track when cursor is over the canvas area\n if (x >= 0 && x <= rect.width && y >= 0 && y <= rect.height) {\n mouseRef.current.x = x;\n mouseRef.current.y = y;\n } else {\n mouseRef.current.x = -9999;\n mouseRef.current.y = -9999;\n }\n }\n\n // mousemove is managed exclusively by the IntersectionObserver below\n\n const startTime = performance.now();\n\n function draw(now: number) {\n if (!ctx || !canvas) return;\n // Skip drawing when offscreen — re-entry handled by IntersectionObserver\n if (!visibleRef.current) return;\n const { w, h } = sizeRef.current;\n ctx.clearRect(0, 0, w, h);\n\n const elapsed = now - startTime;\n const nodes = nodesRef.current;\n const cols = colsRef.current;\n const mx = mouseRef.current.x;\n const my = mouseRef.current.y;\n const isReduced = reducedMotionRef.current;\n\n // Update node positions with smooth interpolation\n const lerp = 0.08; // Easing factor — lower = smoother/slower return\n for (let i = 0; i < nodes.length; i++) {\n const n = nodes[i];\n let targetX = n.baseX;\n let targetY = n.baseY;\n\n if (!isReduced) {\n // Sine wave ambient motion\n const phase = (n.baseX + n.baseY) * 0.01;\n targetX += Math.sin(elapsed * SINE_SPEED + phase) * SINE_AMPLITUDE;\n targetY += Math.cos(elapsed * SINE_SPEED * 0.7 + phase * 1.3) * SINE_AMPLITUDE;\n\n // Mouse repulsion\n const dx = targetX - mx;\n const dy = targetY - my;\n const distSq = dx * dx + dy * dy;\n if (distSq < MOUSE_RADIUS * MOUSE_RADIUS && distSq > 0) {\n const dist = Math.sqrt(distSq);\n const force = (1 - dist / MOUSE_RADIUS) * MOUSE_FORCE;\n targetX += (dx / dist) * force;\n targetY += (dy / dist) * force;\n }\n }\n\n // Smooth interpolation toward target (eases in and springs back)\n n.x += (targetX - n.x) * lerp;\n n.y += (targetY - n.y) * lerp;\n }\n\n // Draw lines using grid topology (right neighbor + bottom neighbor only)\n // This is O(n) instead of O(n^2)\n // Canvas API requires rgb/hex — oklch not supported. White is correct: mesh always sits on dark hero panel.\n ctx.strokeStyle = `rgba(255, 255, 255, ${LINE_OPACITY})`;\n ctx.lineWidth = 0.5;\n ctx.beginPath();\n for (let i = 0; i < nodes.length; i++) {\n const a = nodes[i];\n // Right neighbor\n const rightIdx = i + 1;\n if (rightIdx < nodes.length && rightIdx % cols !== 0) {\n const b = nodes[rightIdx];\n ctx.moveTo(a.x, a.y);\n ctx.lineTo(b.x, b.y);\n }\n // Bottom neighbor\n const bottomIdx = i + cols;\n if (bottomIdx < nodes.length) {\n const b = nodes[bottomIdx];\n ctx.moveTo(a.x, a.y);\n ctx.lineTo(b.x, b.y);\n }\n }\n ctx.stroke();\n\n // Draw dots — batched into single path for performance\n ctx.fillStyle = `rgba(255, 255, 255, ${DOT_OPACITY})`;\n ctx.beginPath();\n for (let i = 0; i < nodes.length; i++) {\n const n = nodes[i];\n ctx.moveTo(n.x + DOT_RADIUS, n.y);\n ctx.arc(n.x, n.y, DOT_RADIUS, 0, Math.PI * 2);\n }\n ctx.fill();\n\n if (!reducedMotionRef.current) {\n animRef.current = requestAnimationFrame(draw);\n }\n }\n\n // IntersectionObserver — pause RAF and mousemove when canvas is offscreen\n const observer = new IntersectionObserver(\n ([entry]) => {\n visibleRef.current = entry.isIntersecting;\n if (entry.isIntersecting) {\n document.addEventListener(\"mousemove\", onMouseMove);\n if (!reducedMotionRef.current) {\n cancelAnimationFrame(animRef.current);\n animRef.current = requestAnimationFrame(draw);\n }\n } else {\n document.removeEventListener(\"mousemove\", onMouseMove);\n mouseRef.current = { x: -9999, y: -9999 };\n }\n },\n { threshold: 0 }\n );\n observer.observe(canvas);\n\n // For reduced motion, draw once statically\n if (reducedMotionRef.current) {\n draw(startTime);\n } else {\n animRef.current = requestAnimationFrame(draw);\n }\n\n return () => {\n observer.disconnect();\n cancelAnimationFrame(animRef.current);\n clearTimeout(resizeTimer);\n window.removeEventListener(\"resize\", debouncedResize);\n document.removeEventListener(\"mousemove\", onMouseMove);\n mql.removeEventListener(\"change\", motionHandler);\n };\n }, [buildGrid]);\n\n return (\n \n );\n}\n", "type": "registry:ui" } ], "meta": { "layer": "signal", "pattern": "C" }, "type": "registry:ui" }