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