Springy Cursor Effect

An interactive React component that generates a trail of emoji particles following the cursor with a spring-like motion, creating a smooth and dynamic elastic effect in real time.

1
'use client';
2
3
import React, { useEffect, useRef } from 'react';
4
5
interface SpringyCursorProps {
6
emoji?: string;
7
wrapperElement?: HTMLElement;
8
zIndex?: number;
9
}
10
11
const SpringyCursor: React.FC<SpringyCursorProps> = ({
12
emoji = '⚽',
13
wrapperElement,
14
zIndex,
15
}) => {
16
const canvasRef = useRef<HTMLCanvasElement | null>(null);
17
const particlesRef = useRef<any[]>([]);
18
const cursorRef = useRef({ x: 0, y: 0 });
19
const animationFrameRef = useRef<number | null>(null);
20
21
const nDots = 7;
22
const DELTAT = 0.01;
23
const SEGLEN = 10;
24
const SPRINGK = 10;
25
const MASS = 1;
26
const GRAVITY = 50;
27
const RESISTANCE = 10;
28
const STOPVEL = 0.1;
29
const STOPACC = 0.1;
30
const DOTSIZE = 11;
31
const BOUNCE = 0.7;
32
33
useEffect(() => {
34
const prefersReducedMotion = window.matchMedia(
35
'(prefers-reduced-motion: reduce)'
36
);
37
let canvas: HTMLCanvasElement | null = null;
38
let context: CanvasRenderingContext2D | null = null;
39
40
const init = () => {
41
if (prefersReducedMotion.matches) {
42
console.log(
43
'This browser has prefers reduced motion turned on, so the cursor did not init'
44
);
45
return false;
46
}
47
48
canvas = canvasRef.current;
49
if (!canvas) return;
50
51
context = canvas.getContext('2d');
52
if (!context) return;
53
54
canvas.style.top = '0px';
55
canvas.style.left = '0px';
56
canvas.style.pointerEvents = 'none';
57
canvas.style.zIndex = zIndex ? zIndex.toString() : '';
58
59
if (wrapperElement) {
60
canvas.style.position = 'absolute';
61
wrapperElement.appendChild(canvas);
62
canvas.width = wrapperElement.clientWidth;
63
canvas.height = wrapperElement.clientHeight;
64
} else {
65
canvas.style.position = 'fixed';
66
document.body.appendChild(canvas);
67
canvas.width = window.innerWidth;
68
canvas.height = window.innerHeight;
69
}
70
71
// Save emoji as an image for performance
72
context.font = '16px serif';
73
context.textBaseline = 'middle';
74
context.textAlign = 'center';
75
76
const measurements = context.measureText(emoji);
77
const bgCanvas = document.createElement('canvas');
78
const bgContext = bgCanvas.getContext('2d');
79
80
if (bgContext) {
81
bgCanvas.width = measurements.width;
82
bgCanvas.height = measurements.actualBoundingBoxAscent * 2;
83
84
bgContext.textAlign = 'center';
85
bgContext.font = '16px serif';
86
bgContext.textBaseline = 'middle';
87
bgContext.fillText(
88
emoji,
89
bgCanvas.width / 2,
90
measurements.actualBoundingBoxAscent
91
);
92
93
for (let i = 0; i < nDots; i++) {
94
particlesRef.current[i] = new Particle(bgCanvas);
95
}
96
}
97
98
bindEvents();
99
loop();
100
};
101
102
const bindEvents = () => {
103
const element = wrapperElement || document.body;
104
element.addEventListener('mousemove', onMouseMove);
105
element.addEventListener('touchmove', onTouchMove, { passive: true });
106
element.addEventListener('touchstart', onTouchMove, { passive: true });
107
window.addEventListener('resize', onWindowResize);
108
};
109
110
const onWindowResize = () => {
111
if (!canvasRef.current) return;
112
113
if (wrapperElement) {
114
canvasRef.current.width = wrapperElement.clientWidth;
115
canvasRef.current.height = wrapperElement.clientHeight;
116
} else {
117
canvasRef.current.width = window.innerWidth;
118
canvasRef.current.height = window.innerHeight;
119
}
120
};
121
122
const onTouchMove = (e: TouchEvent) => {
123
if (e.touches.length > 0) {
124
if (wrapperElement) {
125
const boundingRect = wrapperElement.getBoundingClientRect();
126
cursorRef.current.x = e.touches[0].clientX - boundingRect.left;
127
cursorRef.current.y = e.touches[0].clientY - boundingRect.top;
128
} else {
129
cursorRef.current.x = e.touches[0].clientX;
130
cursorRef.current.y = e.touches[0].clientY;
131
}
132
}
133
};
134
135
const onMouseMove = (e: MouseEvent) => {
136
if (wrapperElement) {
137
const boundingRect = wrapperElement.getBoundingClientRect();
138
cursorRef.current.x = e.clientX - boundingRect.left;
139
cursorRef.current.y = e.clientY - boundingRect.top;
140
} else {
141
cursorRef.current.x = e.clientX;
142
cursorRef.current.y = e.clientY;
143
}
144
};
145
146
const updateParticles = () => {
147
if (!canvasRef.current || !context) return;
148
149
canvasRef.current.width = canvasRef.current.width;
150
151
// follow mouse
152
particlesRef.current[0].position.x = cursorRef.current.x;
153
particlesRef.current[0].position.y = cursorRef.current.y;
154
155
// Start from 2nd dot
156
for (let i = 1; i < nDots; i++) {
157
let spring = new Vec(0, 0);
158
159
if (i > 0) {
160
springForce(i - 1, i, spring);
161
}
162
163
if (i < nDots - 1) {
164
springForce(i + 1, i, spring);
165
}
166
167
let resist = new Vec(
168
-particlesRef.current[i].velocity.x * RESISTANCE,
169
-particlesRef.current[i].velocity.y * RESISTANCE
170
);
171
172
let accel = new Vec(
173
(spring.X + resist.X) / MASS,
174
(spring.Y + resist.Y) / MASS + GRAVITY
175
);
176
177
particlesRef.current[i].velocity.x += DELTAT * accel.X;
178
particlesRef.current[i].velocity.y += DELTAT * accel.Y;
179
180
if (
181
Math.abs(particlesRef.current[i].velocity.x) < STOPVEL &&
182
Math.abs(particlesRef.current[i].velocity.y) < STOPVEL &&
183
Math.abs(accel.X) < STOPACC &&
184
Math.abs(accel.Y) < STOPACC
185
) {
186
particlesRef.current[i].velocity.x = 0;
187
particlesRef.current[i].velocity.y = 0;
188
}
189
190
particlesRef.current[i].position.x +=
191
particlesRef.current[i].velocity.x;
192
particlesRef.current[i].position.y +=
193
particlesRef.current[i].velocity.y;
194
195
let height = canvasRef.current.clientHeight;
196
let width = canvasRef.current.clientWidth;
197
198
if (particlesRef.current[i].position.y >= height - DOTSIZE - 1) {
199
if (particlesRef.current[i].velocity.y > 0) {
200
particlesRef.current[i].velocity.y =
201
BOUNCE * -particlesRef.current[i].velocity.y;
202
}
203
particlesRef.current[i].position.y = height - DOTSIZE - 1;
204
}
205
206
if (particlesRef.current[i].position.x >= width - DOTSIZE) {
207
if (particlesRef.current[i].velocity.x > 0) {
208
particlesRef.current[i].velocity.x =
209
BOUNCE * -particlesRef.current[i].velocity.x;
210
}
211
particlesRef.current[i].position.x = width - DOTSIZE - 1;
212
}
213
214
if (particlesRef.current[i].position.x < 0) {
215
if (particlesRef.current[i].velocity.x < 0) {
216
particlesRef.current[i].velocity.x =
217
BOUNCE * -particlesRef.current[i].velocity.x;
218
}
219
particlesRef.current[i].position.x = 0;
220
}
221
222
particlesRef.current[i].draw(context);
223
}
224
};
225
226
const loop = () => {
227
updateParticles();
228
animationFrameRef.current = requestAnimationFrame(loop);
229
};
230
231
class Vec {
232
X: number;
233
Y: number;
234
235
constructor(X: number, Y: number) {
236
this.X = X;
237
this.Y = Y;
238
}
239
}
240
241
function springForce(i: number, j: number, spring: Vec) {
242
let dx =
243
particlesRef.current[i].position.x - particlesRef.current[j].position.x;
244
let dy =
245
particlesRef.current[i].position.y - particlesRef.current[j].position.y;
246
let len = Math.sqrt(dx * dx + dy * dy);
247
if (len > SEGLEN) {
248
let springF = SPRINGK * (len - SEGLEN);
249
spring.X += (dx / len) * springF;
250
spring.Y += (dy / len) * springF;
251
}
252
}
253
254
class Particle {
255
position: { x: number; y: number };
256
velocity: { x: number; y: number };
257
canv: HTMLCanvasElement;
258
259
constructor(canvasItem: HTMLCanvasElement) {
260
this.position = { x: cursorRef.current.x, y: cursorRef.current.y };
261
this.velocity = { x: 0, y: 0 };
262
this.canv = canvasItem;
263
}
264
265
draw(context: CanvasRenderingContext2D) {
266
context.drawImage(
267
this.canv,
268
this.position.x - this.canv.width / 2,
269
this.position.y - this.canv.height / 2,
270
this.canv.width,
271
this.canv.height
272
);
273
}
274
}
275
276
init();
277
278
return () => {
279
if (canvas) {
280
canvas.remove();
281
}
282
if (animationFrameRef.current) {
283
cancelAnimationFrame(animationFrameRef.current);
284
}
285
const element = wrapperElement || document.body;
286
element.removeEventListener('mousemove', onMouseMove);
287
element.removeEventListener('touchmove', onTouchMove);
288
element.removeEventListener('touchstart', onTouchMove);
289
window.removeEventListener('resize', onWindowResize);
290
};
291
}, [emoji, wrapperElement]);
292
293
return <canvas ref={canvasRef} />;
294
};
295
296
export default SpringyCursor;
297

Props

PropTypeDefaultDescription
emojistringyou can change the emoji to your need
wrapperElementHTMLElement