{ "name": "particle-galaxy", "type": "registry:ui", "dependencies": ["three"], "files": [ { "path": "components/ui/particle-galaxy.tsx", "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useEffect, useRef, useState } from \"react\"\nimport * as THREE from \"three\"\n\ninterface ParticleGalaxyProps {\n className?: string\n /** Number of particles in the galaxy (affects performance) */\n particleCount?: number\n /** Base size of particles */\n particleSize?: number\n /** Speed of automatic rotation */\n rotationSpeed?: number\n /** Number of spiral arms */\n spiralArms?: number\n /** Array of 3 color values for the galaxy */\n colors?: [string, string, string]\n /** Strength of mouse interaction (0-1) */\n mouseInfluence?: number\n /** Enable automatic rotation */\n autoRotate?: boolean\n /** Blend mode: 'additive' for dark backgrounds, 'normal' for light backgrounds */\n blendMode?: \"additive\" | \"normal\"\n /** How spread out the galaxy is (1-5) */\n spread?: number\n /** Particle density/opacity (0-1) */\n density?: number\n /** Glow intensity (0-100) */\n glow?: number\n /** Enable gentle camera movement */\n cameraMovement?: boolean\n /** Concentration of particles toward center (0-1) */\n centerConcentration?: number\n /** Enable particle pulsation animation */\n pulsate?: boolean\n /** Speed of particle pulsation (0.1-2) */\n pulsateSpeed?: number\n /** Enable mouse wheel zoom */\n enableZoom?: boolean\n /** Enable click and drag rotation */\n enableDrag?: boolean\n /** Enable touch gestures on mobile */\n enableTouch?: boolean\n /** Rotation damping factor (0-1) - higher = more responsive */\n damping?: number\n /** Min zoom distance */\n minZoom?: number\n /** Max zoom distance */\n maxZoom?: number\n}\n\nexport function ParticleGalaxy({\n className,\n particleCount = 10000,\n particleSize = 0.02,\n rotationSpeed = 0.001,\n spiralArms = 3,\n colors: colorProp = [\"#4f46e5\", \"#8b5cf6\", \"#ec4899\"],\n mouseInfluence = 0.5,\n autoRotate = true,\n blendMode = \"additive\",\n spread = 2.5,\n density = 0.7,\n glow = 60,\n cameraMovement = true,\n centerConcentration = 0.5,\n pulsate = true,\n pulsateSpeed = 1,\n enableZoom = true,\n enableDrag = true,\n enableTouch = true,\n damping = 0.1,\n minZoom = 1.5,\n maxZoom = 10,\n}: ParticleGalaxyProps) {\n const containerRef = useRef(null)\n const canvasRef = useRef(null)\n const rendererRef = useRef(null)\n const sceneRef = useRef(null)\n const cameraRef = useRef(null)\n const particlesRef = useRef(null)\n const mouseRef = useRef({ x: 0, y: 0 })\n const frameRef = useRef(undefined)\n const [dimensions, setDimensions] = useState({ width: 0, height: 0 })\n\n // 3D interaction state\n const isDraggingRef = useRef(false)\n const previousMouseRef = useRef({ x: 0, y: 0 })\n const targetRotationRef = useRef({ x: 0, y: 0 })\n const currentRotationRef = useRef({ x: 0, y: 0 })\n const currentTiltRef = useRef({ x: 0, y: 0 })\n const targetZoomRef = useRef(3)\n const currentZoomRef = useRef(3)\n\n useEffect(() => {\n if (!containerRef.current || !canvasRef.current) return\n\n const container = containerRef.current\n\n // Get initial dimensions\n const updateDimensions = () => {\n const rect = container.getBoundingClientRect()\n setDimensions({ width: rect.width, height: rect.height })\n }\n\n updateDimensions()\n\n // Setup ResizeObserver\n const resizeObserver = new ResizeObserver(updateDimensions)\n resizeObserver.observe(container)\n\n return () => {\n resizeObserver.disconnect()\n }\n }, [])\n\n useEffect(() => {\n if (\n !canvasRef.current ||\n !containerRef.current ||\n dimensions.width === 0 ||\n dimensions.height === 0\n )\n return\n\n const canvas = canvasRef.current\n const container = containerRef.current\n\n // Scene\n const scene = new THREE.Scene()\n sceneRef.current = scene\n\n // Camera\n const camera = new THREE.PerspectiveCamera(\n 75,\n dimensions.width / dimensions.height,\n 0.1,\n 1000\n )\n camera.position.z = 3\n cameraRef.current = camera\n\n // Renderer with transparent background\n const renderer = new THREE.WebGLRenderer({\n canvas,\n alpha: true, // Transparent background\n antialias: true,\n })\n renderer.setSize(dimensions.width, dimensions.height)\n renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))\n rendererRef.current = renderer\n\n // Create galaxy particles\n const geometry = new THREE.BufferGeometry()\n const positions = new Float32Array(particleCount * 3)\n const colors = new Float32Array(particleCount * 3)\n const scales = new Float32Array(particleCount)\n\n // Convert hex colors to RGB\n const colorPalette = [\n new THREE.Color(colorProp[0]),\n new THREE.Color(colorProp[1]),\n new THREE.Color(colorProp[2]),\n ]\n\n for (let i = 0; i < particleCount; i++) {\n const i3 = i * 3\n\n // Spiral galaxy distribution with customizable spread\n const radius = Math.pow(Math.random(), centerConcentration) * spread\n const spinAngle = radius * spiralArms\n const branchAngle =\n ((i % spiralArms) / spiralArms) * Math.PI * 2 + spinAngle\n\n // Randomness for organic feel\n const randomnessStrength = 0.3 * (spread / 2.5)\n const randomX =\n Math.pow(Math.random(), 3) *\n (Math.random() < 0.5 ? 1 : -1) *\n randomnessStrength\n const randomY =\n Math.pow(Math.random(), 3) *\n (Math.random() < 0.5 ? 1 : -1) *\n randomnessStrength\n const randomZ =\n Math.pow(Math.random(), 3) *\n (Math.random() < 0.5 ? 1 : -1) *\n randomnessStrength\n\n positions[i3] = Math.cos(branchAngle) * radius + randomX\n positions[i3 + 1] = randomY\n positions[i3 + 2] = Math.sin(branchAngle) * radius + randomZ\n\n // Colors - mix based on distance from center\n const mixedColor = colorPalette[i % 3]!.clone()\n const centerDistance = radius / spread\n\n // For normal blend mode (light backgrounds), darken colors\n if (blendMode === \"normal\") {\n mixedColor.lerp(new THREE.Color(\"#000000\"), centerDistance * 0.5)\n } else {\n // For additive blend mode (dark backgrounds), brighten toward center\n mixedColor.lerp(new THREE.Color(\"#ffffff\"), 1 - centerDistance)\n }\n\n colors[i3] = mixedColor.r\n colors[i3 + 1] = mixedColor.g\n colors[i3 + 2] = mixedColor.b\n\n // Variable scale for depth\n scales[i] = Math.random()\n }\n\n geometry.setAttribute(\"position\", new THREE.BufferAttribute(positions, 3))\n geometry.setAttribute(\"color\", new THREE.BufferAttribute(colors, 3))\n geometry.setAttribute(\"scale\", new THREE.BufferAttribute(scales, 1))\n\n // Custom shader material for better particle rendering\n const material = new THREE.ShaderMaterial({\n uniforms: {\n uTime: { value: 0 },\n uSize: { value: particleSize * 100 },\n uGlow: { value: glow / 100 },\n uDensity: { value: density },\n uPulsate: { value: pulsate ? 1.0 : 0.0 },\n uPulsateSpeed: { value: pulsateSpeed },\n },\n vertexShader: `\n uniform float uTime;\n uniform float uSize;\n uniform float uPulsate;\n uniform float uPulsateSpeed;\n attribute float scale;\n varying vec3 vColor;\n \n void main() {\n vColor = color;\n vec4 modelPosition = modelMatrix * vec4(position, 1.0);\n \n // Pulsate particles if enabled\n if (uPulsate > 0.5) {\n float pulsateValue = sin(uTime * uPulsateSpeed + position.x * 10.0) * 0.5 + 0.5;\n modelPosition.y += pulsateValue * 0.02;\n }\n \n vec4 viewPosition = viewMatrix * modelPosition;\n vec4 projectedPosition = projectionMatrix * viewPosition;\n gl_Position = projectedPosition;\n \n gl_PointSize = uSize * scale * (1.0 / -viewPosition.z);\n }\n `,\n fragmentShader: `\n varying vec3 vColor;\n uniform float uGlow;\n uniform float uDensity;\n \n void main() {\n // Circular particles with customizable glow\n float distanceToCenter = distance(gl_PointCoord, vec2(0.5));\n float strength = uGlow / distanceToCenter - (uGlow * 2.0);\n strength = max(0.0, strength);\n \n gl_FragColor = vec4(vColor, strength * uDensity);\n }\n `,\n transparent: true,\n blending:\n blendMode === \"additive\" ? THREE.AdditiveBlending : THREE.NormalBlending,\n depthWrite: false,\n vertexColors: true,\n })\n\n const particles = new THREE.Points(geometry, material)\n scene.add(particles)\n particlesRef.current = particles\n\n // ===== 3D INTERACTION CONTROLS =====\n\n // Mouse hover for subtle tilt (only when not dragging)\n const handleMouseMove = (event: MouseEvent) => {\n if (isDraggingRef.current) return\n const rect = container.getBoundingClientRect()\n mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1\n mouseRef.current.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1)\n }\n\n const handleMouseLeave = () => {\n if (!isDraggingRef.current) {\n mouseRef.current.x = 0\n mouseRef.current.y = 0\n }\n }\n\n // Drag to rotate\n const handleMouseDown = (event: MouseEvent) => {\n if (!enableDrag) return\n isDraggingRef.current = true\n previousMouseRef.current = { x: event.clientX, y: event.clientY }\n container.style.cursor = \"grabbing\"\n }\n\n const handleMouseMoveGlobal = (event: MouseEvent) => {\n if (!isDraggingRef.current || !enableDrag) return\n\n const deltaX = event.clientX - previousMouseRef.current.x\n const deltaY = event.clientY - previousMouseRef.current.y\n\n targetRotationRef.current.y += deltaX * 0.005\n targetRotationRef.current.x += deltaY * 0.005\n\n // Clamp vertical rotation\n targetRotationRef.current.x = Math.max(\n -Math.PI / 2,\n Math.min(Math.PI / 2, targetRotationRef.current.x)\n )\n\n previousMouseRef.current = { x: event.clientX, y: event.clientY }\n }\n\n const handleMouseUp = () => {\n if (!enableDrag) return\n isDraggingRef.current = false\n container.style.cursor = enableDrag ? \"grab\" : \"default\"\n }\n\n // Mouse wheel zoom\n const handleWheel = (event: WheelEvent) => {\n if (!enableZoom) return\n event.preventDefault()\n\n const zoomSpeed = 0.001\n targetZoomRef.current += event.deltaY * zoomSpeed\n targetZoomRef.current = Math.max(\n minZoom,\n Math.min(maxZoom, targetZoomRef.current)\n )\n }\n\n // Touch support\n let touchStartDistance = 0\n let touchStartZoom = 3\n\n const getTouchDistance = (touches: TouchList) => {\n if (touches.length < 2) return 0\n const dx = touches[0]!.clientX - touches[1]!.clientX\n const dy = touches[0]!.clientY - touches[1]!.clientY\n return Math.sqrt(dx * dx + dy * dy)\n }\n\n const handleTouchStart = (event: TouchEvent) => {\n if (!enableTouch) return\n\n if (event.touches.length === 2) {\n // Pinch zoom\n touchStartDistance = getTouchDistance(event.touches)\n touchStartZoom = targetZoomRef.current\n } else if (event.touches.length === 1 && enableDrag) {\n // Single touch drag\n isDraggingRef.current = true\n previousMouseRef.current = {\n x: event.touches[0]!.clientX,\n y: event.touches[0]!.clientY,\n }\n }\n }\n\n const handleTouchMove = (event: TouchEvent) => {\n if (!enableTouch) return\n\n if (event.touches.length === 2 && enableZoom) {\n // Pinch to zoom\n event.preventDefault()\n const currentDistance = getTouchDistance(event.touches)\n const zoomFactor = touchStartDistance / currentDistance\n targetZoomRef.current = touchStartZoom * zoomFactor\n targetZoomRef.current = Math.max(\n minZoom,\n Math.min(maxZoom, targetZoomRef.current)\n )\n } else if (\n event.touches.length === 1 &&\n isDraggingRef.current &&\n enableDrag\n ) {\n // Drag to rotate\n const deltaX = event.touches[0]!.clientX - previousMouseRef.current.x\n const deltaY = event.touches[0]!.clientY - previousMouseRef.current.y\n\n targetRotationRef.current.y += deltaX * 0.005\n targetRotationRef.current.x += deltaY * 0.005\n targetRotationRef.current.x = Math.max(\n -Math.PI / 2,\n Math.min(Math.PI / 2, targetRotationRef.current.x)\n )\n\n previousMouseRef.current = {\n x: event.touches[0]!.clientX,\n y: event.touches[0]!.clientY,\n }\n }\n }\n\n const handleTouchEnd = () => {\n if (!enableTouch) return\n isDraggingRef.current = false\n touchStartDistance = 0\n }\n\n // Add event listeners\n container.addEventListener(\"mousemove\", handleMouseMove)\n container.addEventListener(\"mouseleave\", handleMouseLeave)\n container.addEventListener(\"mousedown\", handleMouseDown)\n document.addEventListener(\"mousemove\", handleMouseMoveGlobal)\n document.addEventListener(\"mouseup\", handleMouseUp)\n container.addEventListener(\"wheel\", handleWheel, { passive: false })\n container.addEventListener(\"touchstart\", handleTouchStart, { passive: false })\n container.addEventListener(\"touchmove\", handleTouchMove, { passive: false })\n container.addEventListener(\"touchend\", handleTouchEnd)\n\n // Set cursor style\n container.style.cursor = enableDrag ? \"grab\" : \"default\"\n\n // Animation\n const clock = new THREE.Clock()\n const animate = () => {\n const elapsedTime = clock.getElapsedTime()\n\n // Update material uniforms\n if (material.uniforms && material.uniforms.uTime) {\n material.uniforms.uTime.value = elapsedTime\n }\n\n if (particles) {\n // Smooth damping for rotation\n currentRotationRef.current.x +=\n (targetRotationRef.current.x - currentRotationRef.current.x) * damping\n currentRotationRef.current.y +=\n (targetRotationRef.current.y - currentRotationRef.current.y) * damping\n\n // Auto-rotate when enabled and not being dragged\n if (autoRotate && !isDraggingRef.current) {\n targetRotationRef.current.y += rotationSpeed\n }\n\n // Calculate target tilt based on mouse position\n const targetTiltX =\n !isDraggingRef.current && mouseInfluence > 0\n ? mouseRef.current.y * mouseInfluence * 0.3\n : 0\n const targetTiltY =\n !isDraggingRef.current && mouseInfluence > 0\n ? mouseRef.current.x * mouseInfluence * 0.3\n : 0\n\n // Smoothly interpolate current tilt\n const tiltDamping = 0.05\n currentTiltRef.current.x +=\n (targetTiltX - currentTiltRef.current.x) * tiltDamping\n currentTiltRef.current.y +=\n (targetTiltY - currentTiltRef.current.y) * tiltDamping\n\n // Apply rotation + tilt\n particles.rotation.x =\n currentRotationRef.current.x + currentTiltRef.current.x\n particles.rotation.y =\n currentRotationRef.current.y + currentTiltRef.current.y\n }\n\n // Smooth zoom damping\n currentZoomRef.current +=\n (targetZoomRef.current - currentZoomRef.current) * damping\n camera.position.z = currentZoomRef.current\n\n // Optional gentle camera movement\n if (cameraMovement && !isDraggingRef.current) {\n camera.position.x = Math.sin(elapsedTime * 0.1) * 0.2\n camera.position.y = Math.cos(elapsedTime * 0.15) * 0.2\n } else if (isDraggingRef.current) {\n // Smoothly return camera to center when dragging\n camera.position.x += (0 - camera.position.x) * 0.1\n camera.position.y += (0 - camera.position.y) * 0.1\n }\n\n camera.lookAt(scene.position)\n\n renderer.render(scene, camera)\n frameRef.current = requestAnimationFrame(animate)\n }\n\n animate()\n\n // Cleanup\n return () => {\n container.removeEventListener(\"mousemove\", handleMouseMove)\n container.removeEventListener(\"mouseleave\", handleMouseLeave)\n container.removeEventListener(\"mousedown\", handleMouseDown)\n document.removeEventListener(\"mousemove\", handleMouseMoveGlobal)\n document.removeEventListener(\"mouseup\", handleMouseUp)\n container.removeEventListener(\"wheel\", handleWheel)\n container.removeEventListener(\"touchstart\", handleTouchStart)\n container.removeEventListener(\"touchmove\", handleTouchMove)\n container.removeEventListener(\"touchend\", handleTouchEnd)\n if (frameRef.current) {\n cancelAnimationFrame(frameRef.current)\n }\n geometry.dispose()\n material.dispose()\n renderer.dispose()\n }\n }, [\n dimensions,\n particleCount,\n particleSize,\n rotationSpeed,\n spiralArms,\n colorProp,\n mouseInfluence,\n autoRotate,\n blendMode,\n spread,\n density,\n glow,\n cameraMovement,\n centerConcentration,\n pulsate,\n pulsateSpeed,\n enableZoom,\n enableDrag,\n enableTouch,\n damping,\n minZoom,\n maxZoom,\n ])\n\n return (\n
\n \n
\n )\n}\n", "type": "registry:ui" } ] }