effects#canvas#particles
Particle Network
Connected dots that drift slowly, forming constellation-like connections when near each other
Implementation Guide
# Particle Network Effect
> Connected dots that drift slowly, forming constellation-like connections when particles are near each other. Mouse interaction creates new connections and attracts particles.
## Quick Start
```tsx
import ParticleNetwork from '@/components/examples/effects/particle-network';
export default function Hero() {
return (
<div className="relative h-screen">
<ParticleNetwork particleColor="#007AFF" lineColor="#007AFF" />
<div className="relative z-10">{/* Your content goes here */}</div>
</div>
);
}
```
## Props
| Prop | Type | Default | Description |
| ------------- | ------ | --------- | ---------------------------------------------- |
| particleCount | number | 80 | Number of particles in the network |
| particleColor | string | '#007AFF' | Hex color for particle dots |
| lineColor | string | '#007AFF' | Hex color for connection lines |
| maxDistance | number | 150 | Maximum distance (px) for particle connections |
| particleSize | number | 2 | Radius of each particle in pixels |
| speed | number | 0.5 | Base movement speed (0.1 - 2.0) |
| mouseRadius | number | 200 | Radius (px) for mouse interaction |
## Full Implementation
```tsx
'use client';
import { useEffect, useRef, useCallback } from 'react';
interface ParticleNetworkProps {
particleCount?: number;
particleColor?: string;
lineColor?: string;
maxDistance?: number;
particleSize?: number;
speed?: number;
mouseRadius?: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
}
export default function ParticleNetwork({
particleCount = 80,
particleColor = '#007AFF',
lineColor = '#007AFF',
maxDistance = 150,
particleSize = 2,
speed = 0.5,
mouseRadius = 200,
}: ParticleNetworkProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const mouseRef = useRef<{ x: number | null; y: number | null }>({
x: null,
y: null,
});
const animationFrameRef = useRef<number>(0);
const prefersReducedMotionRef = useRef<boolean>(false);
const initializeParticles = useCallback(
(width: number, height: number) => {
const particles: Particle[] = [];
for (let i = 0; i < particleCount; i++) {
const angle = Math.random() * Math.PI * 2;
const velocityMagnitude = (Math.random() * 0.5 + 0.5) * speed;
particles.push({
x: Math.random() * width,
y: Math.random() * height,
vx: Math.cos(angle) * velocityMagnitude,
vy: Math.sin(angle) * velocityMagnitude,
});
}
return particles;
},
[particleCount, speed]
);
const hexToRgb = useCallback((hex: string): { r: number; g: number; b: number } => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 122, b: 255 };
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotionRef.current = mediaQuery.matches;
const handleMotionPreferenceChange = (event: MediaQueryListEvent) => {
prefersReducedMotionRef.current = event.matches;
};
mediaQuery.addEventListener('change', handleMotionPreferenceChange);
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
context.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
if (particlesRef.current.length === 0 || particlesRef.current.length !== particleCount) {
particlesRef.current = initializeParticles(rect.width, rect.height);
}
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
const handleMouseMove = (event: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
mouseRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
};
const handleMouseLeave = () => {
mouseRef.current = { x: null, y: null };
};
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
const lineColorRgb = hexToRgb(lineColor);
const animate = () => {
const rect = canvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
context.fillStyle = '#0a0a0a';
context.fillRect(0, 0, width, height);
const particles = particlesRef.current;
const isReducedMotion = prefersReducedMotionRef.current;
const motionMultiplier = isReducedMotion ? 0.05 : 1;
// Update particle positions
for (const particle of particles) {
particle.x += particle.vx * motionMultiplier;
particle.y += particle.vy * motionMultiplier;
// Wrap around edges
if (particle.x < 0) particle.x = width;
if (particle.x > width) particle.x = 0;
if (particle.y < 0) particle.y = height;
if (particle.y > height) particle.y = 0;
// Add slight random drift
if (!isReducedMotion) {
particle.vx += (Math.random() - 0.5) * 0.01;
particle.vy += (Math.random() - 0.5) * 0.01;
// Limit velocity
const currentSpeed = Math.sqrt(particle.vx ** 2 + particle.vy ** 2);
if (currentSpeed > speed * 1.5) {
particle.vx = (particle.vx / currentSpeed) * speed;
particle.vy = (particle.vy / currentSpeed) * speed;
}
}
}
// Draw connections between particles
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < maxDistance) {
const opacity = 1 - distance / maxDistance;
context.strokeStyle = `rgba(${lineColorRgb.r}, ${lineColorRgb.g}, ${lineColorRgb.b}, ${opacity * 0.6})`;
context.lineWidth = 1;
context.beginPath();
context.moveTo(particles[i].x, particles[i].y);
context.lineTo(particles[j].x, particles[j].y);
context.stroke();
}
}
}
// Draw connections to mouse
const mouse = mouseRef.current;
if (mouse.x !== null && mouse.y !== null) {
for (const particle of particles) {
const dx = particle.x - mouse.x;
const dy = particle.y - mouse.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < mouseRadius) {
const opacity = 1 - distance / mouseRadius;
context.strokeStyle = `rgba(${lineColorRgb.r}, ${lineColorRgb.g}, ${lineColorRgb.b}, ${opacity * 0.8})`;
context.lineWidth = 1.5;
context.beginPath();
context.moveTo(particle.x, particle.y);
context.lineTo(mouse.x, mouse.y);
context.stroke();
// Attract particles slightly toward mouse
if (!isReducedMotion) {
const attractionStrength = 0.02 * (1 - distance / mouseRadius);
particle.vx -= (dx / distance) * attractionStrength;
particle.vy -= (dy / distance) * attractionStrength;
}
}
}
// Draw mouse position indicator
context.beginPath();
context.arc(mouse.x, mouse.y, 4, 0, Math.PI * 2);
context.fillStyle = particleColor;
context.fill();
}
// Draw particles
context.fillStyle = particleColor;
for (const particle of particles) {
context.beginPath();
context.arc(particle.x, particle.y, particleSize, 0, Math.PI * 2);
context.fill();
}
animationFrameRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
cancelAnimationFrame(animationFrameRef.current);
window.removeEventListener('resize', resizeCanvas);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
mediaQuery.removeEventListener('change', handleMotionPreferenceChange);
};
}, [
particleCount,
particleColor,
lineColor,
maxDistance,
particleSize,
speed,
mouseRadius,
initializeParticles,
hexToRgb,
]);
return (
<canvas
ref={canvasRef}
className="h-full w-full"
style={{ background: '#0a0a0a' }}
aria-hidden="true"
/>
);
}
```
## Customization
### Brand Colors
Match your brand by customizing particle and line colors:
```tsx
<ParticleNetwork particleColor="#FF3B30" lineColor="#FF3B30" />
```
### Dense Network
Create a more connected, dense visualization:
```tsx
<ParticleNetwork particleCount={120} maxDistance={200} particleSize={3} speed={0.3} />
```
### Sparse Constellation
For a minimal, star-like appearance:
```tsx
<ParticleNetwork
particleCount={40}
maxDistance={100}
particleSize={1.5}
speed={0.2}
mouseRadius={150}
/>
```
### High Interactivity
Make the mouse interaction more prominent:
```tsx
<ParticleNetwork mouseRadius={300} speed={0.8} particleCount={100} />
```
### Dual Color Scheme
Use different colors for particles and lines:
```tsx
<ParticleNetwork particleColor="#FFFFFF" lineColor="#007AFF" />
```
## Performance Considerations
- **Reduce `particleCount`** on mobile devices (40-50 recommended)
- The connection algorithm is O(n^2), so particle count significantly affects performance
- **Decrease `maxDistance`** to reduce the number of lines drawn
- Consider using `requestIdleCallback` for particle initialization on slower devices
- Canvas is automatically scaled for retina displays using devicePixelRatio
### Mobile Optimization Example
```tsx
import { useEffect, useState } from 'react';
function ResponsiveParticleNetwork() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile(window.innerWidth < 768);
}, []);
return <ParticleNetwork particleCount={isMobile ? 40 : 80} maxDistance={isMobile ? 100 : 150} />;
}
```
## Accessibility
- Respects `prefers-reduced-motion` - particle movement is reduced to 5% of normal speed when users prefer reduced motion
- The canvas has `aria-hidden="true"` as it is purely decorative
- Ensure content above the effect has proper contrast ratios
- Content should have `position: relative; z-index: 10` to appear above the canvas
## Browser Support
- Modern browsers with Canvas 2D support
- requestAnimationFrame support required
- MediaQueryList.addEventListener support (Chrome 39+, Firefox 55+, Safari 14+)
- Works in all modern browsers including Chrome, Firefox, Safari, and Edge