Springy Cursor Effect

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

Props

PropTypeDefaultDescription
emojistringyou can change the emoji to your need
wrapperElementHTMLElement