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