Bubble Cursor Effect

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

1
'use client';
2
import React, { useEffect, useRef } from 'react';
3
4
interface BubbleCursorProps {
5
wrapperElement?: HTMLElement;
6
}
7
8
class Particle {
9
lifeSpan: number;
10
initialLifeSpan: number;
11
velocity: { x: number; y: number };
12
position: { x: number; y: number };
13
baseDimension: number;
14
15
constructor(x: number, y: number) {
16
this.initialLifeSpan = Math.floor(Math.random() * 60 + 60);
17
this.lifeSpan = this.initialLifeSpan;
18
this.velocity = {
19
x: (Math.random() < 0.5 ? -1 : 1) * (Math.random() / 10),
20
y: -0.4 + Math.random() * -1,
21
};
22
this.position = { x, y };
23
this.baseDimension = 4;
24
}
25
26
update(context: CanvasRenderingContext2D) {
27
this.position.x += this.velocity.x;
28
this.position.y += this.velocity.y;
29
this.velocity.x += ((Math.random() < 0.5 ? -1 : 1) * 2) / 75;
30
this.velocity.y -= Math.random() / 600;
31
this.lifeSpan--;
32
33
const scale =
34
0.2 + (this.initialLifeSpan - this.lifeSpan) / this.initialLifeSpan;
35
36
context.fillStyle = '#e6f1f7';
37
context.strokeStyle = '#3a92c5';
38
context.beginPath();
39
context.arc(
40
this.position.x - (this.baseDimension / 2) * scale,
41
this.position.y - this.baseDimension / 2,
42
this.baseDimension * scale,
43
0,
44
2 * Math.PI
45
);
46
47
context.stroke();
48
context.fill();
49
context.closePath();
50
}
51
}
52
const BubbleCursor: React.FC<BubbleCursorProps> = ({ wrapperElement }) => {
53
const canvasRef = useRef<HTMLCanvasElement | null>(null);
54
const particlesRef = useRef<Particle[]>([]);
55
const cursorRef = useRef({ x: 0, y: 0 });
56
const animationFrameRef = useRef<number | null>(null);
57
58
useEffect(() => {
59
const prefersReducedMotion = window.matchMedia(
60
'(prefers-reduced-motion: reduce)'
61
);
62
let canvas: HTMLCanvasElement | null = null;
63
let context: CanvasRenderingContext2D | null = null;
64
let width = window.innerWidth;
65
let height = window.innerHeight;
66
67
const init = () => {
68
if (prefersReducedMotion.matches) {
69
console.log(
70
'This browser has prefers reduced motion turned on, so the cursor did not init'
71
);
72
return false;
73
}
74
75
canvas = canvasRef.current;
76
if (!canvas) return;
77
78
context = canvas.getContext('2d');
79
if (!context) return;
80
81
canvas.style.top = '0px';
82
canvas.style.left = '0px';
83
canvas.style.pointerEvents = 'none';
84
85
if (wrapperElement) {
86
canvas.style.position = 'absolute';
87
wrapperElement.appendChild(canvas);
88
canvas.width = wrapperElement.clientWidth;
89
canvas.height = wrapperElement.clientHeight;
90
} else {
91
canvas.style.position = 'fixed';
92
document.body.appendChild(canvas);
93
canvas.width = width;
94
canvas.height = height;
95
}
96
97
bindEvents();
98
loop();
99
};
100
101
const bindEvents = () => {
102
const element = wrapperElement || document.body;
103
element.addEventListener('mousemove', onMouseMove);
104
element.addEventListener('touchmove', onTouchMove, { passive: true });
105
element.addEventListener('touchstart', onTouchMove, { passive: true });
106
window.addEventListener('resize', onWindowResize);
107
};
108
109
const onWindowResize = () => {
110
width = window.innerWidth;
111
height = window.innerHeight;
112
113
if (!canvasRef.current) return;
114
115
if (wrapperElement) {
116
canvasRef.current.width = wrapperElement.clientWidth;
117
canvasRef.current.height = wrapperElement.clientHeight;
118
} else {
119
canvasRef.current.width = width;
120
canvasRef.current.height = height;
121
}
122
};
123
124
const onTouchMove = (e: TouchEvent) => {
125
if (e.touches.length > 0) {
126
for (let i = 0; i < e.touches.length; i++) {
127
addParticle(e.touches[i].clientX, e.touches[i].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
addParticle(cursorRef.current.x, cursorRef.current.y);
143
};
144
145
const addParticle = (x: number, y: number) => {
146
particlesRef.current.push(new Particle(x, y));
147
};
148
149
const updateParticles = () => {
150
if (!canvas || !context) return;
151
152
if (particlesRef.current.length === 0) {
153
return;
154
}
155
156
context.clearRect(0, 0, canvas.width, canvas.height);
157
158
// Update
159
for (let i = 0; i < particlesRef.current.length; i++) {
160
particlesRef.current[i].update(context);
161
}
162
163
// Remove dead particles
164
for (let i = particlesRef.current.length - 1; i >= 0; i--) {
165
if (particlesRef.current[i].lifeSpan < 0) {
166
particlesRef.current.splice(i, 1);
167
}
168
}
169
170
if (particlesRef.current.length === 0) {
171
context.clearRect(0, 0, canvas.width, canvas.height);
172
}
173
};
174
175
const loop = () => {
176
updateParticles();
177
animationFrameRef.current = requestAnimationFrame(loop);
178
};
179
180
init();
181
182
return () => {
183
if (canvas) {
184
canvas.remove();
185
}
186
if (animationFrameRef.current) {
187
cancelAnimationFrame(animationFrameRef.current);
188
}
189
const element = wrapperElement || document.body;
190
element.removeEventListener('mousemove', onMouseMove);
191
element.removeEventListener('touchmove', onTouchMove);
192
element.removeEventListener('touchstart', onTouchMove);
193
window.removeEventListener('resize', onWindowResize);
194
};
195
}, [wrapperElement]);
196
197
return <canvas ref={canvasRef} />;
198
};
199
200
export default BubbleCursor;
201