Bubble Cursor Effect

An interactive React component that creates beautiful animated bubbles that follow cursor movement with smooth physics-based animations.

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