effects#voronoi#shatter

Voronoi Shatter

Click triggers content to explode into geometric Voronoi-style fragments that animate outward with physics

Implementation Guide
# Voronoi Shatter Effect

> Click triggers content to explode into geometric Voronoi-style fragments that animate outward with physics. A destructive micro-interaction that creates dramatic visual impact.

## Quick Start

```tsx
import VoronoiShatter from '@/components/examples/effects/voronoi-shatter';

export default function Hero() {
  return (
    <div className="relative h-screen">
      <VoronoiShatter colors={['#007AFF', '#FF3B30', '#5856D6']} />
      <div className="relative z-10">{/* Your content goes here */}</div>
    </div>
  );
}
```

## Props

| Prop             | Type     | Default                                                 | Description                                        |
| ---------------- | -------- | ------------------------------------------------------- | -------------------------------------------------- |
| cellCount        | number   | 40                                                      | Number of Voronoi cells to generate                |
| colors           | string[] | ['#007AFF', '#FF3B30', '#5856D6', '#FF9500', '#34C759'] | Array of hex colors for cells                      |
| gravity          | number   | 0.3                                                     | Gravity strength affecting fragment fall (0-1)     |
| fragmentLifetime | number   | 2000                                                    | How long fragments persist in milliseconds         |
| autoReset        | boolean  | true                                                    | Automatically regenerate cells after all shattered |
| resetDelay       | number   | 500                                                     | Delay before reset in milliseconds                 |

## Full Implementation

```tsx
'use client';

import { useEffect, useRef, useCallback } from 'react';

interface VoronoiShatterProps {
  cellCount?: number;
  colors?: string[];
  gravity?: number;
  fragmentLifetime?: number;
  autoReset?: boolean;
  resetDelay?: number;
}

interface VoronoiCell {
  id: number;
  seedX: number;
  seedY: number;
  vertices: { x: number; y: number }[];
  color: string;
}

interface Fragment {
  id: number;
  vertices: { x: number; y: number }[];
  centroidX: number;
  centroidY: number;
  velocityX: number;
  velocityY: number;
  rotation: number;
  rotationVelocity: number;
  color: string;
  opacity: number;
  createdAt: number;
}

export default function VoronoiShatter({
  cellCount = 40,
  colors = ['#007AFF', '#FF3B30', '#5856D6', '#FF9500', '#34C759'],
  gravity = 0.3,
  fragmentLifetime = 2000,
  autoReset = true,
  resetDelay = 500,
}: VoronoiShatterProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const cellsRef = useRef<VoronoiCell[]>([]);
  const fragmentsRef = useRef<Fragment[]>([]);
  const animationFrameRef = useRef<number>(0);
  const prefersReducedMotionRef = useRef<boolean>(false);
  const dimensionsRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 });
  const isResettingRef = useRef<boolean>(false);
  const fragmentIdCounterRef = useRef<number>(0);

  const hexToRgba = useCallback((hex: string, alpha: number): string => {
    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    if (result) {
      const r = parseInt(result[1], 16);
      const g = parseInt(result[2], 16);
      const b = parseInt(result[3], 16);
      return `rgba(${r}, ${g}, ${b}, ${alpha})`;
    }
    return `rgba(0, 122, 255, ${alpha})`;
  }, []);

  const computeConvexHull = useCallback(
    (points: { x: number; y: number }[]): { x: number; y: number }[] => {
      if (points.length < 3) return points;

      // Find leftmost point
      let leftmost = 0;
      for (let i = 1; i < points.length; i++) {
        if (points[i].x < points[leftmost].x) {
          leftmost = i;
        }
      }

      const hull: { x: number; y: number }[] = [];
      let current = leftmost;
      let iterations = 0;
      const maxIterations = points.length * 2;

      do {
        hull.push(points[current]);
        let next = 0;

        for (let i = 0; i < points.length; i++) {
          if (next === current) {
            next = i;
            continue;
          }

          const cross =
            (points[i].x - points[current].x) * (points[next].y - points[current].y) -
            (points[i].y - points[current].y) * (points[next].x - points[current].x);

          if (cross > 0) {
            next = i;
          }
        }

        current = next;
        iterations++;
      } while (current !== leftmost && iterations < maxIterations);

      return hull;
    },
    []
  );

  const generateVoronoiCells = useCallback(
    (width: number, height: number): VoronoiCell[] => {
      // Generate random seed points
      const seeds: { x: number; y: number }[] = [];
      for (let i = 0; i < cellCount; i++) {
        seeds.push({
          x: Math.random() * width,
          y: Math.random() * height,
        });
      }

      // Simple Voronoi approximation using pixel sampling
      const gridSize = 8;
      const cellPixels: Map<number, { x: number; y: number }[]> = new Map();

      for (let x = 0; x < width; x += gridSize) {
        for (let y = 0; y < height; y += gridSize) {
          let minDistance = Infinity;
          let closestSeedIndex = 0;

          for (let i = 0; i < seeds.length; i++) {
            const dx = x - seeds[i].x;
            const dy = y - seeds[i].y;
            const distance = dx * dx + dy * dy;
            if (distance < minDistance) {
              minDistance = distance;
              closestSeedIndex = i;
            }
          }

          if (!cellPixels.has(closestSeedIndex)) {
            cellPixels.set(closestSeedIndex, []);
          }
          cellPixels.get(closestSeedIndex)!.push({ x, y });
        }
      }

      // Convert pixel regions to polygon vertices using convex hull
      const cells: VoronoiCell[] = [];
      cellPixels.forEach((pixels, seedIndex) => {
        if (pixels.length < 3) return;

        // Compute convex hull of the pixel points
        const hull = computeConvexHull(pixels);
        if (hull.length < 3) return;

        cells.push({
          id: seedIndex,
          seedX: seeds[seedIndex].x,
          seedY: seeds[seedIndex].y,
          vertices: hull,
          color: colors[seedIndex % colors.length],
        });
      });

      return cells;
    },
    [cellCount, colors, computeConvexHull]
  );

  const shatterAtPoint = useCallback(
    (clickX: number, clickY: number) => {
      if (prefersReducedMotionRef.current) return;

      const cells = cellsRef.current;
      const newFragments: Fragment[] = [];

      for (const cell of cells) {
        // Check if click is near this cell's centroid
        const centroidX = cell.vertices.reduce((sum, v) => sum + v.x, 0) / cell.vertices.length;
        const centroidY = cell.vertices.reduce((sum, v) => sum + v.y, 0) / cell.vertices.length;

        const distanceToClick = Math.sqrt((centroidX - clickX) ** 2 + (centroidY - clickY) ** 2);
        const maxDistance = 300;

        if (distanceToClick < maxDistance) {
          // Calculate explosion velocity based on distance from click
          const explosionStrength = 1 - distanceToClick / maxDistance;
          const angle = Math.atan2(centroidY - clickY, centroidX - clickX);
          const speed = (8 + Math.random() * 12) * explosionStrength;

          const fragment: Fragment = {
            id: fragmentIdCounterRef.current++,
            vertices: cell.vertices.map((v) => ({ x: v.x - centroidX, y: v.y - centroidY })),
            centroidX,
            centroidY,
            velocityX: Math.cos(angle) * speed + (Math.random() - 0.5) * 4,
            velocityY: Math.sin(angle) * speed + (Math.random() - 0.5) * 4,
            rotation: 0,
            rotationVelocity: (Math.random() - 0.5) * 0.3,
            color: cell.color,
            opacity: 1,
            createdAt: Date.now(),
          };

          newFragments.push(fragment);
        }
      }

      // Remove shattered cells from the main cells array
      cellsRef.current = cells.filter((cell) => {
        const centroidX = cell.vertices.reduce((sum, v) => sum + v.x, 0) / cell.vertices.length;
        const centroidY = cell.vertices.reduce((sum, v) => sum + v.y, 0) / cell.vertices.length;
        const distanceToClick = Math.sqrt((centroidX - clickX) ** 2 + (centroidY - clickY) ** 2);
        return distanceToClick >= 300;
      });

      fragmentsRef.current = [...fragmentsRef.current, ...newFragments];

      // Schedule reset if autoReset is enabled and all cells are shattered
      if (autoReset && cellsRef.current.length === 0 && !isResettingRef.current) {
        isResettingRef.current = true;
        setTimeout(() => {
          const { width, height } = dimensionsRef.current;
          cellsRef.current = generateVoronoiCells(width, height);
          isResettingRef.current = false;
        }, fragmentLifetime + resetDelay);
      }
    },
    [autoReset, fragmentLifetime, resetDelay, generateVoronoiCells]
  );

  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`;

      dimensionsRef.current = { width: rect.width, height: rect.height };

      // Regenerate cells on resize
      cellsRef.current = generateVoronoiCells(rect.width, rect.height);
      fragmentsRef.current = [];
    };

    resizeCanvas();
    window.addEventListener('resize', resizeCanvas);

    const handleClick = (event: MouseEvent) => {
      const rect = canvas.getBoundingClientRect();
      const clickX = event.clientX - rect.left;
      const clickY = event.clientY - rect.top;
      shatterAtPoint(clickX, clickY);
    };

    const handleTouch = (event: TouchEvent) => {
      event.preventDefault();
      const rect = canvas.getBoundingClientRect();
      const touch = event.touches[0];
      const touchX = touch.clientX - rect.left;
      const touchY = touch.clientY - rect.top;
      shatterAtPoint(touchX, touchY);
    };

    canvas.addEventListener('click', handleClick);
    canvas.addEventListener('touchstart', handleTouch, { passive: false });

    const animate = () => {
      const { width, height } = dimensionsRef.current;
      const now = Date.now();

      // Clear canvas with gradient background
      const gradient = context.createLinearGradient(0, 0, width, height);
      gradient.addColorStop(0, '#0a0a0a');
      gradient.addColorStop(1, '#1a1a2e');
      context.fillStyle = gradient;
      context.fillRect(0, 0, width, height);

      // Draw intact cells
      for (const cell of cellsRef.current) {
        if (cell.vertices.length < 3) continue;

        context.beginPath();
        context.moveTo(cell.vertices[0].x, cell.vertices[0].y);
        for (let i = 1; i < cell.vertices.length; i++) {
          context.lineTo(cell.vertices[i].x, cell.vertices[i].y);
        }
        context.closePath();

        context.fillStyle = hexToRgba(cell.color, 0.3);
        context.fill();

        context.strokeStyle = hexToRgba(cell.color, 0.6);
        context.lineWidth = 1;
        context.stroke();
      }

      // Update and draw fragments
      const activeFragments: Fragment[] = [];
      const isReducedMotion = prefersReducedMotionRef.current;

      for (const fragment of fragmentsRef.current) {
        const age = now - fragment.createdAt;
        if (age > fragmentLifetime) continue;

        // Update physics (skip if reduced motion)
        if (!isReducedMotion) {
          fragment.velocityY += gravity * 0.16; // 60fps gravity
          fragment.centroidX += fragment.velocityX;
          fragment.centroidY += fragment.velocityY;
          fragment.rotation += fragment.rotationVelocity;
        }

        // Calculate opacity fade
        const progress = age / fragmentLifetime;
        fragment.opacity = 1 - progress;

        // Draw fragment
        context.save();
        context.translate(fragment.centroidX, fragment.centroidY);
        context.rotate(fragment.rotation);

        context.beginPath();
        if (fragment.vertices.length > 0) {
          context.moveTo(fragment.vertices[0].x, fragment.vertices[0].y);
          for (let i = 1; i < fragment.vertices.length; i++) {
            context.lineTo(fragment.vertices[i].x, fragment.vertices[i].y);
          }
          context.closePath();
        }

        context.fillStyle = hexToRgba(fragment.color, 0.4 * fragment.opacity);
        context.fill();

        context.strokeStyle = hexToRgba(fragment.color, 0.8 * fragment.opacity);
        context.lineWidth = 1.5;
        context.stroke();

        context.restore();

        activeFragments.push(fragment);
      }

      fragmentsRef.current = activeFragments;

      // Draw hint text if no interaction yet
      if (cellsRef.current.length > 0 && fragmentsRef.current.length === 0) {
        context.fillStyle = 'rgba(255, 255, 255, 0.4)';
        context.font = '12px ui-monospace, monospace';
        context.textAlign = 'center';
        context.fillText('CLICK TO SHATTER', width / 2, height / 2);
      }

      animationFrameRef.current = requestAnimationFrame(animate);
    };

    animate();

    return () => {
      cancelAnimationFrame(animationFrameRef.current);
      window.removeEventListener('resize', resizeCanvas);
      canvas.removeEventListener('click', handleClick);
      canvas.removeEventListener('touchstart', handleTouch);
      mediaQuery.removeEventListener('change', handleMotionPreferenceChange);
    };
  }, [
    cellCount,
    colors,
    gravity,
    fragmentLifetime,
    autoReset,
    resetDelay,
    generateVoronoiCells,
    shatterAtPoint,
    hexToRgba,
  ]);

  return (
    <canvas
      ref={canvasRef}
      className="h-full w-full cursor-crosshair"
      style={{ background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)' }}
      aria-hidden="true"
    />
  );
}
```

## Customization

### Brand Colors

Match your brand with a custom color palette:

```tsx
<VoronoiShatter colors={['#FF3B30', '#FF9500', '#FFCC00']} />
```

### Dense Mosaic

More cells for a finer, intricate pattern:

```tsx
<VoronoiShatter cellCount={80} gravity={0.2} fragmentLifetime={1500} />
```

### Dramatic Explosion

Heavier gravity and longer fragment life for cinematic effect:

```tsx
<VoronoiShatter gravity={0.6} fragmentLifetime={3000} resetDelay={1000} />
```

### No Auto-Reset

Let fragments disappear without regenerating:

```tsx
<VoronoiShatter autoReset={false} />
```

### Subtle Background

Fewer cells with quick fade for minimal distraction:

```tsx
<VoronoiShatter cellCount={20} fragmentLifetime={1000} gravity={0.15} />
```

### Monochrome Shatter

Single-color minimalist style:

```tsx
<VoronoiShatter colors={['#FFFFFF']} />
```

## Performance Considerations

- **Reduce `cellCount`** on mobile devices (20-30 recommended)
- Voronoi cell generation is O(n \* gridPoints), calculated once on load/resize
- Fragment physics updates are lightweight and run at 60fps
- Each click processes cells within a 300px radius only
- Canvas is automatically scaled for retina displays using devicePixelRatio
- Fragments are garbage collected after `fragmentLifetime` expires

### Mobile Optimization Example

```tsx
import { useEffect, useState } from 'react';

function ResponsiveVoronoiShatter() {
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    setIsMobile(window.innerWidth < 768);
  }, []);

  return (
    <VoronoiShatter cellCount={isMobile ? 25 : 40} fragmentLifetime={isMobile ? 1500 : 2000} />
  );
}
```

## Accessibility

- Respects `prefers-reduced-motion` - shatter animation is completely disabled when users prefer reduced motion
- The canvas has `aria-hidden="true"` as it is purely decorative
- Touch events are supported for mobile interaction
- 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+)
- Touch events for mobile support
- Works in all modern browsers including Chrome, Firefox, Safari, and Edge