Interactive Dots

An interactive React component that adds a dynamic bubble effect, visually tracking cursor movement in real time.

1
'use client';
2
3
import { useEffect, useRef, useCallback } from 'react';
4
5
interface InteractiveDotsProps {
6
backgroundColor?: string;
7
dotColor?: string;
8
gridSpacing?: number;
9
animationSpeed?: number;
10
removeWaveLine?: boolean;
11
}
12
13
const InteractiveDots = ({
14
backgroundColor = '#F0EEE6',
15
dotColor = '#666666',
16
gridSpacing = 30,
17
animationSpeed = 0.005,
18
removeWaveLine = true
19
}: InteractiveDotsProps) => {
20
const canvasRef = useRef<HTMLCanvasElement>(null);
21
const timeRef = useRef<number>(0);
22
const animationFrameId = useRef<number | null>(null);
23
const mouseRef = useRef({ x: 0, y: 0, isDown: false });
24
const ripples = useRef<
25
Array<{ x: number; y: number; time: number; intensity: number }>
26
>([]);
27
const dotsRef = useRef<
28
Array<{
29
x: number;
30
y: number;
31
originalX: number;
32
originalY: number;
33
phase: number;
34
}>
35
>([]);
36
const dprRef = useRef<number>(1);
37
38
const getMouseInfluence = (x: number, y: number): number => {
39
const dx = x - mouseRef.current.x;
40
const dy = y - mouseRef.current.y;
41
const distance = Math.sqrt(dx * dx + dy * dy);
42
const maxDistance = 150;
43
return Math.max(0, 1 - distance / maxDistance);
44
};
45
46
const getRippleInfluence = (
47
x: number,
48
y: number,
49
currentTime: number
50
): number => {
51
let totalInfluence = 0;
52
ripples.current.forEach((ripple) => {
53
const age = currentTime - ripple.time;
54
const maxAge = 3000;
55
if (age < maxAge) {
56
const dx = x - ripple.x;
57
const dy = y - ripple.y;
58
const distance = Math.sqrt(dx * dx + dy * dy);
59
const rippleRadius = (age / maxAge) * 300;
60
const rippleWidth = 60;
61
if (Math.abs(distance - rippleRadius) < rippleWidth) {
62
const rippleStrength = (1 - age / maxAge) * ripple.intensity;
63
const proximityToRipple =
64
1 - Math.abs(distance - rippleRadius) / rippleWidth;
65
totalInfluence += rippleStrength * proximityToRipple;
66
}
67
}
68
});
69
return Math.min(totalInfluence, 2);
70
};
71
72
const initializeDots = useCallback(() => {
73
const canvas = canvasRef.current;
74
if (!canvas) return;
75
76
// Use CSS pixel dimensions for calculations
77
const canvasWidth = canvas.clientWidth;
78
const canvasHeight = canvas.clientHeight;
79
80
const dots: Array<{
81
x: number;
82
y: number;
83
originalX: number;
84
originalY: number;
85
phase: number;
86
}> = [];
87
88
// Create grid of dots
89
for (let x = gridSpacing / 2; x < canvasWidth; x += gridSpacing) {
90
for (let y = gridSpacing / 2; y < canvasHeight; y += gridSpacing) {
91
dots.push({
92
x,
93
y,
94
originalX: x,
95
originalY: y,
96
phase: Math.random() * Math.PI * 2, // Random phase for subtle animation
97
});
98
}
99
}
100
101
dotsRef.current = dots;
102
}, [gridSpacing]);
103
104
const resizeCanvas = useCallback(() => {
105
const canvas = canvasRef.current;
106
if (!canvas) return;
107
108
const dpr = window.devicePixelRatio || 1;
109
dprRef.current = dpr;
110
111
const displayWidth = window.innerWidth;
112
const displayHeight = window.innerHeight;
113
114
// Set the actual size in memory (scaled up for high DPI)
115
canvas.width = displayWidth * dpr;
116
canvas.height = displayHeight * dpr;
117
118
// Scale the canvas back down using CSS
119
canvas.style.width = displayWidth + 'px';
120
canvas.style.height = displayHeight + 'px';
121
122
// Scale the drawing context so everything draws at the correct size
123
const ctx = canvas.getContext('2d');
124
if (ctx) {
125
ctx.scale(dpr, dpr);
126
}
127
128
initializeDots();
129
}, [initializeDots]);
130
131
const handleMouseMove = useCallback((e: MouseEvent) => {
132
const canvas = canvasRef.current;
133
if (!canvas) return;
134
135
const rect = canvas.getBoundingClientRect();
136
mouseRef.current.x = e.clientX - rect.left;
137
mouseRef.current.y = e.clientY - rect.top;
138
}, []);
139
140
const handleMouseDown = useCallback((e: MouseEvent) => {
141
mouseRef.current.isDown = true;
142
const canvas = canvasRef.current;
143
if (!canvas) return;
144
145
const rect = canvas.getBoundingClientRect();
146
const x = e.clientX - rect.left;
147
const y = e.clientY - rect.top;
148
149
ripples.current.push({
150
x,
151
y,
152
time: Date.now(),
153
intensity: 2,
154
});
155
156
const now = Date.now();
157
ripples.current = ripples.current.filter(
158
(ripple) => now - ripple.time < 3000
159
);
160
}, []);
161
162
const handleMouseUp = useCallback(() => {
163
mouseRef.current.isDown = false;
164
}, []);
165
166
const animate = useCallback(() => {
167
const canvas = canvasRef.current;
168
if (!canvas) return;
169
170
const ctx = canvas.getContext('2d');
171
if (!ctx) return;
172
173
timeRef.current += animationSpeed;
174
const currentTime = Date.now();
175
176
// Use CSS pixel dimensions for calculations
177
const canvasWidth = canvas.clientWidth;
178
const canvasHeight = canvas.clientHeight;
179
180
// Clear canvas
181
ctx.fillStyle = backgroundColor;
182
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
183
184
// Update and draw dots
185
dotsRef.current.forEach((dot) => {
186
const mouseInfluence = getMouseInfluence(dot.originalX, dot.originalY);
187
const rippleInfluence = getRippleInfluence(
188
dot.originalX,
189
dot.originalY,
190
currentTime
191
);
192
const totalInfluence = mouseInfluence + rippleInfluence;
193
194
// Keep dots at original positions - no movement
195
dot.x = dot.originalX;
196
dot.y = dot.originalY;
197
198
// Calculate dot properties based on influences - only scaling
199
const baseDotSize = 2;
200
const dotSize =
201
baseDotSize +
202
totalInfluence * 6 +
203
Math.sin(timeRef.current + dot.phase) * 0.5;
204
const opacity = Math.max(
205
0.3,
206
0.6 +
207
totalInfluence * 0.4 +
208
Math.abs(Math.sin(timeRef.current * 0.5 + dot.phase)) * 0.1
209
);
210
211
// Draw dot
212
ctx.beginPath();
213
ctx.arc(dot.x, dot.y, dotSize, 0, Math.PI * 2);
214
215
// Color with opacity
216
const red = Number.parseInt(dotColor.slice(1, 3), 16);
217
const green = Number.parseInt(dotColor.slice(3, 5), 16);
218
const blue = Number.parseInt(dotColor.slice(5, 7), 16);
219
ctx.fillStyle = `rgba(${red}, ${green}, ${blue}, ${opacity})`;
220
ctx.fill();
221
});
222
223
// Draw ripple effects
224
if (!removeWaveLine) {
225
ripples.current.forEach((ripple) => {
226
const age = currentTime - ripple.time;
227
const maxAge = 3000;
228
if (age < maxAge) {
229
const progress = age / maxAge;
230
const radius = progress * 300;
231
const alpha = (1 - progress) * 0.3 * ripple.intensity;
232
233
// Outer ripple
234
ctx.beginPath();
235
ctx.strokeStyle = `rgba(100, 100, 100, ${alpha})`;
236
ctx.lineWidth = 2;
237
ctx.arc(ripple.x, ripple.y, radius, 0, 2 * Math.PI);
238
ctx.stroke();
239
240
// Inner ripple
241
const innerRadius = progress * 150;
242
const innerAlpha = (1 - progress) * 0.2 * ripple.intensity;
243
ctx.beginPath();
244
ctx.strokeStyle = `rgba(120, 120, 120, ${innerAlpha})`;
245
ctx.lineWidth = 1;
246
ctx.arc(ripple.x, ripple.y, innerRadius, 0, 2 * Math.PI);
247
ctx.stroke();
248
}
249
});
250
}
251
252
animationFrameId.current = requestAnimationFrame(animate);
253
}, [backgroundColor, dotColor, removeWaveLine, animationSpeed]);
254
255
useEffect(() => {
256
const canvas = canvasRef.current;
257
if (!canvas) return;
258
259
resizeCanvas();
260
261
const handleResize = () => resizeCanvas();
262
263
window.addEventListener('resize', handleResize);
264
canvas.addEventListener('mousemove', handleMouseMove);
265
canvas.addEventListener('mousedown', handleMouseDown);
266
canvas.addEventListener('mouseup', handleMouseUp);
267
268
animate();
269
270
return () => {
271
window.removeEventListener('resize', handleResize);
272
canvas.removeEventListener('mousemove', handleMouseMove);
273
canvas.removeEventListener('mousedown', handleMouseDown);
274
canvas.removeEventListener('mouseup', handleMouseUp);
275
276
if (animationFrameId.current) {
277
cancelAnimationFrame(animationFrameId.current);
278
animationFrameId.current = null;
279
}
280
timeRef.current = 0;
281
ripples.current = [];
282
dotsRef.current = [];
283
};
284
}, [animate, resizeCanvas, handleMouseMove, handleMouseDown, handleMouseUp]);
285
286
return (
287
<div
288
className='absolute inset-0 w-full h-full overflow-hidden'
289
style={{ backgroundColor }}
290
>
291
<canvas ref={canvasRef} className='block w-full h-full' />
292
</div>
293
);
294
};
295
296
export default InteractiveDots;
297

Props

PropTypeDefaultDescription
backgroundColorstring'#F0EEE6'Background color of the canvas.
dotColorstring'#666666'Color of the dots.
gridSpacingnumber30Spacing between the dots in the grid.
animationSpeednumber0.005Speed of the dot animation.
removeWaveLinebooleantrueWhether to remove the animated wave line (if true, the wave is not shown).