Character Cursor Effect

A React component that tracks cursor movement and creates characters that radiate outward, generating a dynamic and expanding effect.

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