Delicate Ascii Dots Effect

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 DelicateAsciiDotsProps {
6
backgroundColor?: string;
7
textColor?: string;
8
gridSize?: number;
9
removeWaveLine?: boolean;
10
animationSpeed?: number;
11
}
12
13
interface Wave {
14
x: number;
15
y: number;
16
frequency: number;
17
amplitude: number;
18
phase: number;
19
speed: number;
20
}
21
22
interface GridCell {
23
char: string;
24
opacity: number;
25
}
26
27
const DelicateAsciiDots = ({
28
backgroundColor = '#000000',
29
textColor = '85, 85, 85',
30
gridSize = 80,
31
removeWaveLine = true,
32
animationSpeed = 0.75,
33
}: DelicateAsciiDotsProps) => {
34
const canvasRef = useRef<HTMLCanvasElement>(null);
35
const containerRef = useRef<HTMLDivElement>(null);
36
const mouseRef = useRef({ x: 0, y: 0, isDown: false });
37
const wavesRef = useRef<Wave[]>([]);
38
const timeRef = useRef<number>(0);
39
const animationFrameId = useRef<number | null>(null);
40
const clickWaves = useRef<
41
Array<{ x: number; y: number; time: number; intensity: number }>
42
>([]);
43
const dimensionsRef = useRef({ width: 0, height: 0 });
44
45
const CHARS =
46
'⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⠁⠂⠄⠈⠐⠠⡀⢀⠃⠅⠘⠨⠊⠋⠌⠍⠎⠏⠑⠒⠓⠔⠕⠖⠗⠙⠚⠛⠜⠝⠞⠟⠡⠢⠣⠤⠥⠦⠧⠩⠪⠫⠬⠭⠮⠯⠱⠲⠳⠴⠵⠶⠷⠹⠺⠻⠼⠽⠾⠿⡁⡂⡃⡄⡅⡆⡇⡉⡊⡋⡌⡍⡎⡏⡑⡒⡓⡔⡕⡖⡗⡙⡚⡛⡜⡝⡞⡟⡡⡢⡣⡤⡥⡦⡧⡩⡪⡫⡬⡭⡮⡯⡱⡲⡳⡴⡵⡶⡷⡹⡺⡻⡼⡽⡾⡿⢁⢂⢃⢄⢅⢆⢇⢉⢊⢋⢌⢍⢎⢏⢑⢒⢓⢔⢕⢖⢗⢙⢚⢛⢜⢝⢞⢟⢡⢢⢣⢤⢥⢦⢧⢩⢪⢫⢬⢭⢮⢯⢱⢲⢳⢴⢵⢶⢷⢹⢺⢻⢼⢽⢾⢿⣀⣁⣂⣃⣄⣅⣆⣇⣉⣊⣋⣌⣍⣎⣏⣑⣒⣓⣔⣕⣖⣗⣙⣚⣛⣜⣝⣞⣟⣡⣢⣣⣤⣥⣦⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿';
47
48
const resizeCanvas = useCallback(() => {
49
const canvas = canvasRef.current;
50
const container = containerRef.current;
51
if (!canvas || !container) return;
52
53
const containerRect = container.getBoundingClientRect();
54
const width = containerRect.width;
55
const height = containerRect.height;
56
57
// Store dimensions for coordinate calculations
58
dimensionsRef.current = { width, height };
59
60
const dpr = window.devicePixelRatio || 1;
61
62
// Set canvas size to match container
63
canvas.width = width * dpr;
64
canvas.height = height * dpr;
65
66
canvas.style.width = width + 'px';
67
canvas.style.height = height + 'px';
68
69
const ctx = canvas.getContext('2d');
70
if (ctx) {
71
ctx.scale(dpr, dpr);
72
}
73
}, []);
74
75
const handleMouseMove = useCallback((e: MouseEvent) => {
76
const canvas = canvasRef.current;
77
if (!canvas) return;
78
79
const rect = canvas.getBoundingClientRect();
80
const x = e.clientX - rect.left;
81
const y = e.clientY - rect.top;
82
83
mouseRef.current = {
84
x: x,
85
y: y,
86
isDown: mouseRef.current.isDown,
87
};
88
}, []);
89
90
const handleMouseDown = useCallback(
91
(e: MouseEvent) => {
92
mouseRef.current.isDown = true;
93
const canvas = canvasRef.current;
94
if (!canvas) return;
95
96
const rect = canvas.getBoundingClientRect();
97
const x = e.clientX - rect.left;
98
const y = e.clientY - rect.top;
99
100
// Convert screen coordinates to grid coordinates
101
const { width, height } = dimensionsRef.current;
102
const cellWidth = width / gridSize;
103
const cellHeight = height / gridSize;
104
105
const gridX = x / cellWidth;
106
const gridY = y / cellHeight;
107
108
clickWaves.current.push({
109
x: gridX,
110
y: gridY,
111
time: Date.now(),
112
intensity: 2,
113
});
114
115
// Clean up old waves
116
const now = Date.now();
117
clickWaves.current = clickWaves.current.filter(
118
(wave) => now - wave.time < 4000
119
);
120
},
121
[gridSize]
122
);
123
124
const handleMouseUp = useCallback(() => {
125
mouseRef.current.isDown = false;
126
}, []);
127
128
const getClickWaveInfluence = (
129
x: number,
130
y: number,
131
currentTime: number
132
): number => {
133
let totalInfluence = 0;
134
135
clickWaves.current.forEach((wave) => {
136
const age = currentTime - wave.time;
137
const maxAge = 4000;
138
if (age < maxAge) {
139
const dx = x - wave.x;
140
const dy = y - wave.y;
141
const distance = Math.sqrt(dx * dx + dy * dy);
142
const waveRadius = (age / maxAge) * gridSize * 0.8;
143
const waveWidth = gridSize * 0.15;
144
145
if (Math.abs(distance - waveRadius) < waveWidth) {
146
const waveStrength = (1 - age / maxAge) * wave.intensity;
147
const proximityToWave =
148
1 - Math.abs(distance - waveRadius) / waveWidth;
149
totalInfluence +=
150
waveStrength *
151
proximityToWave *
152
Math.sin((distance - waveRadius) * 0.5);
153
}
154
}
155
});
156
157
return totalInfluence;
158
};
159
160
const animate = useCallback(() => {
161
const canvas = canvasRef.current;
162
if (!canvas) return;
163
164
const ctx = canvas.getContext('2d');
165
if (!ctx) return;
166
167
const currentTime = Date.now();
168
timeRef.current += animationSpeed * 0.016;
169
170
const { width, height } = dimensionsRef.current;
171
if (width === 0 || height === 0) return;
172
173
// Clear canvas
174
ctx.fillStyle = backgroundColor;
175
ctx.fillRect(0, 0, width, height);
176
177
const newGrid: (GridCell | null)[][] = Array(gridSize)
178
.fill(0)
179
.map(() => Array(gridSize).fill(null));
180
181
// Calculate cell dimensions
182
const cellWidth = width / gridSize;
183
const cellHeight = height / gridSize;
184
185
// Convert mouse position to grid coordinates
186
const mouseGridX = mouseRef.current.x / cellWidth;
187
const mouseGridY = mouseRef.current.y / cellHeight;
188
189
// Create mouse wave
190
const mouseWave: Wave = {
191
x: mouseGridX,
192
y: mouseGridY,
193
frequency: 0.3,
194
amplitude: 1,
195
phase: timeRef.current * 2,
196
speed: 1,
197
};
198
199
// Calculate wave interference
200
for (let y = 0; y < gridSize; y++) {
201
for (let x = 0; x < gridSize; x++) {
202
let totalWave = 0;
203
204
// Sum all wave contributions
205
const allWaves = wavesRef.current.concat([mouseWave]);
206
207
allWaves.forEach((wave) => {
208
const dx = x - wave.x;
209
const dy = y - wave.y;
210
const dist = Math.sqrt(dx * dx + dy * dy);
211
const falloff = 1 / (1 + dist * 0.1);
212
const value =
213
Math.sin(
214
dist * wave.frequency - timeRef.current * wave.speed + wave.phase
215
) *
216
wave.amplitude *
217
falloff;
218
219
totalWave += value;
220
});
221
222
// Add click wave influence
223
const clickInfluence = getClickWaveInfluence(x, y, currentTime);
224
totalWave += clickInfluence;
225
226
// Enhanced mouse interaction
227
const mouseDistance = Math.sqrt(
228
(x - mouseGridX) ** 2 + (y - mouseGridY) ** 2
229
);
230
if (mouseDistance < gridSize * 0.3) {
231
const mouseEffect = (1 - mouseDistance / (gridSize * 0.3)) * 0.8;
232
totalWave += mouseEffect * Math.sin(timeRef.current * 3);
233
}
234
235
// Map interference pattern to characters and opacity
236
const normalizedWave = (totalWave + 2) / 4;
237
if (Math.abs(totalWave) > 0.2) {
238
const charIndex = Math.min(
239
CHARS.length - 1,
240
Math.max(0, Math.floor(normalizedWave * (CHARS.length - 1)))
241
);
242
const opacity = Math.min(
243
0.9,
244
Math.max(0.4, 0.4 + normalizedWave * 0.5)
245
);
246
247
newGrid[y][x] = {
248
char: CHARS[charIndex] || CHARS[0],
249
opacity: opacity,
250
};
251
}
252
}
253
}
254
255
// Calculate optimal font size
256
const fontSize = Math.min(cellWidth, cellHeight) * 0.8;
257
ctx.font = `${fontSize}px monospace`;
258
ctx.textAlign = 'center';
259
ctx.textBaseline = 'middle';
260
261
// Draw characters
262
for (let y = 0; y < gridSize; y++) {
263
for (let x = 0; x < gridSize; x++) {
264
const cell = newGrid[y][x];
265
if (cell && cell.char && CHARS.includes(cell.char)) {
266
ctx.fillStyle = `rgba(${textColor}, ${cell.opacity})`;
267
ctx.fillText(
268
cell.char,
269
x * cellWidth + cellWidth / 2,
270
y * cellHeight + cellHeight / 2
271
);
272
}
273
}
274
}
275
276
// Draw click wave effects (visual ripples)
277
if (!removeWaveLine) {
278
clickWaves.current.forEach((wave) => {
279
const age = currentTime - wave.time;
280
const maxAge = 4000;
281
if (age < maxAge) {
282
const progress = age / maxAge;
283
const radius = progress * Math.min(width, height) * 0.5;
284
const alpha = (1 - progress) * 0.3 * wave.intensity;
285
286
ctx.beginPath();
287
ctx.strokeStyle = `rgba(${textColor}, ${alpha})`;
288
ctx.lineWidth = 1;
289
ctx.arc(
290
wave.x * cellWidth,
291
wave.y * cellHeight,
292
radius,
293
0,
294
2 * Math.PI
295
);
296
ctx.stroke();
297
}
298
});
299
}
300
301
animationFrameId.current = requestAnimationFrame(animate);
302
}, [backgroundColor, textColor, gridSize, animationSpeed, removeWaveLine]);
303
304
useEffect(() => {
305
// Initialize background waves
306
const waves: Wave[] = [];
307
const numWaves = 4;
308
309
for (let i = 0; i < numWaves; i++) {
310
waves.push({
311
x: gridSize * (0.25 + Math.random() * 0.5),
312
y: gridSize * (0.25 + Math.random() * 0.5),
313
frequency: 0.2 + Math.random() * 0.3,
314
amplitude: 0.5 + Math.random() * 0.5,
315
phase: Math.random() * Math.PI * 2,
316
speed: 0.5 + Math.random() * 0.5,
317
});
318
}
319
320
wavesRef.current = waves;
321
322
const canvas = canvasRef.current;
323
if (!canvas) return;
324
325
// Initial resize
326
resizeCanvas();
327
328
const handleResize = () => {
329
resizeCanvas();
330
};
331
332
window.addEventListener('resize', handleResize);
333
canvas.addEventListener('mousemove', handleMouseMove);
334
canvas.addEventListener('mousedown', handleMouseDown);
335
canvas.addEventListener('mouseup', handleMouseUp);
336
337
// Start animation
338
animate();
339
340
return () => {
341
window.removeEventListener('resize', handleResize);
342
canvas.removeEventListener('mousemove', handleMouseMove);
343
canvas.removeEventListener('mousedown', handleMouseDown);
344
canvas.removeEventListener('mouseup', handleMouseUp);
345
346
if (animationFrameId.current) {
347
cancelAnimationFrame(animationFrameId.current);
348
animationFrameId.current = null;
349
}
350
timeRef.current = 0;
351
clickWaves.current = [];
352
wavesRef.current = [];
353
};
354
}, [
355
animate,
356
resizeCanvas,
357
handleMouseMove,
358
handleMouseDown,
359
handleMouseUp,
360
gridSize,
361
]);
362
363
return (
364
<div
365
ref={containerRef}
366
className='w-[40rem] h-full absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] overflow-hidden'
367
style={{ backgroundColor }}
368
>
369
<canvas ref={canvasRef} className='block w-full h-full' />
370
</div>
371
);
372
};
373
374
export default DelicateAsciiDots;
375

Props

PropTypeDefaultDescription
backgroundColorstring'#000000'Background color of the canvas.
textColorstring'85, 85, 85'RGB color value for the ASCII text dots.
gridSizenumber80Size of the grid for ASCII dot placement.
removeWaveLinebooleantrueWhether to remove the animated wave line (if true, the wave is not shown).
animationSpeednumber0.75Speed of the ASCII dot animation.