Character Cursor Effect

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

1
// @ts-nocheck
2
'use client';
3
import React, { useEffect, useRef } from 'react';
4
5
interface Particle {
6
rotationSign: number;
7
age: number;
8
initialLifeSpan: number;
9
lifeSpan: number;
10
velocity: { x: number; y: number };
11
position: { x: number; y: number };
12
canv: HTMLCanvasElement;
13
update: (context: CanvasRenderingContext2D) => void;
14
}
15
16
interface CharacterCursorProps {
17
characters?: string[];
18
colors?: string[];
19
cursorOffset?: { x: number; y: number };
20
font?: string;
21
characterLifeSpanFunction?: () => number;
22
initialCharacterVelocityFunction?: () => { x: number; y: number };
23
characterVelocityChangeFunctions?: {
24
x_func: (age: number, lifeSpan: number) => number;
25
y_func: (age: number, lifeSpan: number) => number;
26
};
27
characterScalingFunction?: (age: number, lifeSpan: number) => number;
28
characterNewRotationDegreesFunction?: (
29
age: number,
30
lifeSpan: number
31
) => number;
32
wrapperElement?: HTMLElement;
33
}
34
35
const CharacterCursor: React.FC<CharacterCursorProps> = ({
36
characters = ['h', 'e', 'l', 'l', 'o'],
37
colors = ['#6622CC', '#A755C2', '#B07C9E', '#B59194', '#D2A1B8'],
38
cursorOffset = { x: 0, y: 0 },
39
font = '15px serif',
40
characterLifeSpanFunction = () => Math.floor(Math.random() * 60 + 80),
41
initialCharacterVelocityFunction = () => ({
42
x: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5,
43
y: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5,
44
}),
45
characterVelocityChangeFunctions = {
46
x_func: () => (Math.random() < 0.5 ? -1 : 1) / 30,
47
y_func: () => (Math.random() < 0.5 ? -1 : 1) / 15,
48
},
49
characterScalingFunction = (age, lifeSpan) =>
50
Math.max(((lifeSpan - age) / lifeSpan) * 2, 0),
51
characterNewRotationDegreesFunction = (age, lifeSpan) => (lifeSpan - age) / 5,
52
wrapperElement,
53
}) => {
54
const canvasRef = useRef<HTMLCanvasElement | null>(null);
55
const particlesRef = useRef<Particle[]>([]);
56
const cursorRef = useRef({ x: 0, y: 0 });
57
const animationFrameRef = useRef<number | null>(null);
58
const canvImagesRef = useRef<HTMLCanvasElement[]>([]);
59
60
useEffect(() => {
61
const prefersReducedMotion = window.matchMedia(
62
'(prefers-reduced-motion: reduce)'
63
);
64
let canvas: HTMLCanvasElement | null = null;
65
let context: CanvasRenderingContext2D | null = null;
66
let width = window.innerWidth;
67
let height = window.innerHeight;
68
69
const randomPositiveOrNegativeOne = () => (Math.random() < 0.5 ? -1 : 1);
70
71
class Particle {
72
rotationSign: number;
73
age: number;
74
initialLifeSpan: number;
75
lifeSpan: number;
76
velocity: { x: number; y: number };
77
position: { x: number; y: number };
78
canv: HTMLCanvasElement;
79
80
constructor(x: number, y: number, canvasItem: HTMLCanvasElement) {
81
const lifeSpan = characterLifeSpanFunction();
82
this.rotationSign = randomPositiveOrNegativeOne();
83
this.age = 0;
84
this.initialLifeSpan = lifeSpan;
85
this.lifeSpan = lifeSpan;
86
this.velocity = initialCharacterVelocityFunction();
87
this.position = {
88
x: x + cursorOffset.x,
89
y: y + cursorOffset.y,
90
};
91
this.canv = canvasItem;
92
}
93
94
update(context: CanvasRenderingContext2D) {
95
this.position.x += this.velocity.x;
96
this.position.y += this.velocity.y;
97
this.lifeSpan--;
98
this.age++;
99
100
this.velocity.x += characterVelocityChangeFunctions.x_func(
101
this.age,
102
this.initialLifeSpan
103
);
104
this.velocity.y += characterVelocityChangeFunctions.y_func(
105
this.age,
106
this.initialLifeSpan
107
);
108
109
const scale = characterScalingFunction(this.age, this.initialLifeSpan);
110
111
const degrees =
112
this.rotationSign *
113
characterNewRotationDegreesFunction(this.age, this.initialLifeSpan);
114
const radians = degrees * 0.0174533;
115
116
context.translate(this.position.x, this.position.y);
117
context.rotate(radians);
118
119
context.drawImage(
120
this.canv,
121
(-this.canv.width / 2) * scale,
122
-this.canv.height / 2,
123
this.canv.width * scale,
124
this.canv.height * scale
125
);
126
127
context.rotate(-radians);
128
context.translate(-this.position.x, -this.position.y);
129
}
130
}
131
132
const init = () => {
133
if (prefersReducedMotion.matches) {
134
console.log(
135
'This browser has prefers reduced motion turned on, so the cursor did not init'
136
);
137
return false;
138
}
139
140
canvas = canvasRef.current;
141
if (!canvas) return;
142
143
context = canvas.getContext('2d');
144
if (!context) return;
145
146
canvas.style.top = '0px';
147
canvas.style.left = '0px';
148
canvas.style.pointerEvents = 'none';
149
150
if (wrapperElement) {
151
canvas.style.position = 'absolute';
152
wrapperElement.appendChild(canvas);
153
canvas.width = wrapperElement.clientWidth;
154
canvas.height = wrapperElement.clientHeight;
155
} else {
156
canvas.style.position = 'fixed';
157
document.body.appendChild(canvas);
158
canvas.width = width;
159
canvas.height = height;
160
}
161
162
context.font = font;
163
context.textBaseline = 'middle';
164
context.textAlign = 'center';
165
166
characters.forEach((char) => {
167
let measurements = context.measureText(char);
168
let bgCanvas = document.createElement('canvas');
169
let bgContext = bgCanvas.getContext('2d');
170
171
if (bgContext) {
172
bgCanvas.width = measurements.width;
173
bgCanvas.height = measurements.actualBoundingBoxAscent * 2.5;
174
175
bgContext.textAlign = 'center';
176
bgContext.font = font;
177
bgContext.textBaseline = 'middle';
178
var randomColor = colors[Math.floor(Math.random() * colors.length)];
179
bgContext.fillStyle = randomColor;
180
181
bgContext.fillText(
182
char,
183
bgCanvas.width / 2,
184
measurements.actualBoundingBoxAscent
185
);
186
187
canvImagesRef.current.push(bgCanvas);
188
}
189
});
190
191
bindEvents();
192
loop();
193
};
194
195
const bindEvents = () => {
196
const element = wrapperElement || document.body;
197
element.addEventListener('mousemove', onMouseMove);
198
element.addEventListener('touchmove', onTouchMove, { passive: true });
199
element.addEventListener('touchstart', onTouchMove, { passive: true });
200
window.addEventListener('resize', onWindowResize);
201
};
202
203
const onWindowResize = () => {
204
width = window.innerWidth;
205
height = window.innerHeight;
206
207
if (!canvasRef.current) return;
208
209
if (wrapperElement) {
210
canvasRef.current.width = wrapperElement.clientWidth;
211
canvasRef.current.height = wrapperElement.clientHeight;
212
} else {
213
canvasRef.current.width = width;
214
canvasRef.current.height = height;
215
}
216
};
217
218
const onTouchMove = (e: TouchEvent) => {
219
if (e.touches.length > 0) {
220
for (let i = 0; i < e.touches.length; i++) {
221
addParticle(
222
e.touches[i].clientX,
223
e.touches[i].clientY,
224
canvImagesRef.current[
225
Math.floor(Math.random() * canvImagesRef.current.length)
226
]
227
);
228
}
229
}
230
};
231
232
const onMouseMove = (e: MouseEvent) => {
233
if (wrapperElement) {
234
const boundingRect = wrapperElement.getBoundingClientRect();
235
cursorRef.current.x = e.clientX - boundingRect.left;
236
cursorRef.current.y = e.clientY - boundingRect.top;
237
} else {
238
cursorRef.current.x = e.clientX;
239
cursorRef.current.y = e.clientY;
240
}
241
242
addParticle(
243
cursorRef.current.x,
244
cursorRef.current.y,
245
canvImagesRef.current[Math.floor(Math.random() * characters.length)]
246
);
247
};
248
249
const addParticle = (x: number, y: number, img: HTMLCanvasElement) => {
250
particlesRef.current.push(new Particle(x, y, img));
251
};
252
253
const updateParticles = () => {
254
if (!canvas || !context) return;
255
256
if (particlesRef.current.length === 0) {
257
return;
258
}
259
260
context.clearRect(0, 0, canvas.width, canvas.height);
261
262
// Update
263
for (let i = 0; i < particlesRef.current.length; i++) {
264
particlesRef.current[i].update(context);
265
}
266
267
// Remove dead particles
268
for (let i = particlesRef.current.length - 1; i >= 0; i--) {
269
if (particlesRef.current[i].lifeSpan < 0) {
270
particlesRef.current.splice(i, 1);
271
}
272
}
273
274
if (particlesRef.current.length === 0) {
275
context.clearRect(0, 0, canvas.width, canvas.height);
276
}
277
};
278
279
const loop = () => {
280
updateParticles();
281
animationFrameRef.current = requestAnimationFrame(loop);
282
};
283
284
init();
285
286
return () => {
287
if (canvas) {
288
canvas.remove();
289
}
290
if (animationFrameRef.current) {
291
cancelAnimationFrame(animationFrameRef.current);
292
}
293
const element = wrapperElement || document.body;
294
element.removeEventListener('mousemove', onMouseMove);
295
element.removeEventListener('touchmove', onTouchMove);
296
element.removeEventListener('touchstart', onTouchMove);
297
window.removeEventListener('resize', onWindowResize);
298
};
299
}, [
300
characters,
301
colors,
302
cursorOffset,
303
font,
304
characterLifeSpanFunction,
305
initialCharacterVelocityFunction,
306
characterVelocityChangeFunctions,
307
characterScalingFunction,
308
characterNewRotationDegreesFunction,
309
wrapperElement,
310
]);
311
312
return <canvas ref={canvasRef} />;
313
};
314
315
export default CharacterCursor;
316
1
2
## Props
3
4
| Prop | Type | Default | Description |
5
|---------------------------------|---------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|
6
| `characters` | `string[]` | `['h', 'e', 'l', 'l', 'o']` | Array of characters to display as the cursor effect. |
7
| `colors` | `string[]` | `['#6622CC', '#A755C2', '#B07C9E', '#B59194', '#D2A1B8']` | Array of colors for the characters. |
8
| `cursorOffset` | `{ x: number; y: number }` | `{ x: 0, y: 0 }` | Offset for the cursor position. |
9
| `font` | `string` | `'15px serif'` | Font style for the characters. |
10
| `characterLifeSpanFunction` | `() => number` | `() => Math.floor(Math.random() * 60 + 80)` | Function to determine the lifespan of each character in frames. |
11
| `initialCharacterVelocityFunction` | `() => { x: number; y: number }` | `() => ({ x: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5, y: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5 })` | Function to set the initial velocity of each character. |
12
| `characterVelocityChangeFunctions` | `{ x_func: (age: number, lifeSpan: number) => number; y_func: (age: number, lifeSpan: number) => number }` | `{ x_func: () => (Math.random() < 0.5 ? -1 : 1) / 30, y_func: () => (Math.random() < 0.5 ? -1 : 1) / 15 }` | Functions to update the velocity of each character over time for `x` and `y` axes. |
13
| `characterScalingFunction` | `(age: number, lifeSpan: number) => number` | `(age, lifeSpan) => Math.max(((lifeSpan - age) / lifeSpan) * 2, 0)` | Function to determine the scaling of each character based on its age and lifespan. |
14
| `characterNewRotationDegreesFunction` | `(age: number, lifeSpan: number) => number` | `(age, lifeSpan) => (lifeSpan - age) / 5` | Function to determine the rotation of each character in degrees based on its age and lifespan. |
15
| `wrapperElement` | `HTMLElement` | `undefined` | Element that wraps the canvas. Defaults to the full document body if not provided. |