Trail 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 { useEffect, useRef } from 'react';
4
5
interface TrailingCursorProps {
6
element?: HTMLElement;
7
particles?: number;
8
rate?: number;
9
baseImageSrc?: string;
10
}
11
12
const TrailingCursor: React.FC<TrailingCursorProps> = ({
13
element,
14
particles = 15,
15
rate = 0.4,
16
baseImageSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAATCAYAAACk9eypAAAAAXNSR0IArs4c6QAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAADKADAAQAAAABAAAAEwAAAAAChpcNAAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAABqElEQVQoFY3SPUvDQBgH8BREpRHExYiDgmLFl6WC+AYmWeyLg4i7buJX8DMpOujgyxGvUYeCgzhUQUSKKLUS0+ZyptXh8Z5Ti621ekPyJHl+uftfomhaf9Ei5JyxXKfynyEA6EYcLHpwyflT958GAQ7DTABNHd8EbtDbEH2BD5QEQmi2mM8P/Iq+A0SzszEg+3sPjDnDdVEtQKQbMUidHD3xVzf6A9UDEmEm+8h9KTqTVUjT+vB53aHrCbAPiceYq1dQI1Aqv4EhMll0jzv+Y0yiRgCnLRSYyDQHVoqUXe4uKL9l+L7GXC4vkMhE6eW/AOJs9k583ORDUyXMZ8F5SVHVVnllmPNKSFagAJ5DofaqGXw/gHBYg51dIldkmknY3tguv3jOtHR4+MqAzaraJXbEhqHhcQlwGSOi5pytVQHZLN5s0WNe8HPrLYlFsO20RPHkImxsbmHdLJFI76th7Z4SeuF53hTeFLvhRCJRCTKZKxgdnRDbW+iozFJbBMw14/ElwGYc0egMBMFzT21f5Rog33Z7dX02GBm7WV5ZfT5Nn5bE3zuCDe9UxdTpNvK+5AAAAABJRU5ErkJggg==',
17
}) => {
18
const canvasRef = useRef<HTMLCanvasElement | null>(null);
19
const cursorRef = useRef({ x: 0, y: 0 });
20
const particlesRef = useRef<
21
Array<{
22
position: { x: number; y: number };
23
image: HTMLImageElement;
24
move: (context: CanvasRenderingContext2D) => void;
25
}>
26
>([]);
27
const animationFrameRef = useRef<number>();
28
const cursorsInittedRef = useRef(false);
29
30
class Particle {
31
position: { x: number; y: number };
32
image: HTMLImageElement;
33
34
constructor(x: number, y: number, image: HTMLImageElement) {
35
this.position = { x, y };
36
this.image = image;
37
}
38
39
move(context: CanvasRenderingContext2D) {
40
context.drawImage(this.image, this.position.x, this.position.y);
41
}
42
}
43
44
useEffect(() => {
45
const baseImage = new Image();
46
baseImage.src = baseImageSrc;
47
48
const prefersReducedMotion = window.matchMedia(
49
'(prefers-reduced-motion: reduce)'
50
);
51
52
const hasWrapperEl = element !== undefined;
53
const targetElement = hasWrapperEl ? element : document.body;
54
const canvas = document.createElement('canvas');
55
const context = canvas.getContext('2d');
56
57
if (!context) return;
58
59
canvasRef.current = canvas;
60
canvas.style.top = '0px';
61
canvas.style.left = '0px';
62
canvas.style.pointerEvents = 'none';
63
64
if (hasWrapperEl) {
65
canvas.style.position = 'absolute';
66
targetElement.appendChild(canvas);
67
canvas.width = targetElement.clientWidth;
68
canvas.height = targetElement.clientHeight;
69
} else {
70
canvas.style.position = 'fixed';
71
document.body.appendChild(canvas);
72
canvas.width = window.innerWidth;
73
canvas.height = window.innerHeight;
74
}
75
76
const onMouseMove = (e: MouseEvent) => {
77
if (hasWrapperEl && element) {
78
const boundingRect = element.getBoundingClientRect();
79
cursorRef.current.x = e.clientX - boundingRect.left;
80
cursorRef.current.y = e.clientY - boundingRect.top;
81
} else {
82
cursorRef.current.x = e.clientX;
83
cursorRef.current.y = e.clientY;
84
}
85
86
if (cursorsInittedRef.current === false) {
87
cursorsInittedRef.current = true;
88
for (let i = 0; i < particles; i++) {
89
particlesRef.current.push(
90
new Particle(cursorRef.current.x, cursorRef.current.y, baseImage)
91
);
92
}
93
}
94
};
95
96
const onWindowResize = () => {
97
if (hasWrapperEl && element) {
98
canvas.width = element.clientWidth;
99
canvas.height = element.clientHeight;
100
} else {
101
canvas.width = window.innerWidth;
102
canvas.height = window.innerHeight;
103
}
104
};
105
106
const updateParticles = () => {
107
context.clearRect(0, 0, canvas.width, canvas.height);
108
109
let x = cursorRef.current.x;
110
let y = cursorRef.current.y;
111
112
particlesRef.current.forEach((particle, index) => {
113
const nextParticle =
114
particlesRef.current[index + 1] || particlesRef.current[0];
115
116
particle.position.x = x;
117
particle.position.y = y;
118
particle.move(context);
119
x += (nextParticle.position.x - particle.position.x) * rate;
120
y += (nextParticle.position.y - particle.position.y) * rate;
121
});
122
};
123
124
const loop = () => {
125
updateParticles();
126
animationFrameRef.current = requestAnimationFrame(loop);
127
};
128
129
if (!prefersReducedMotion.matches) {
130
targetElement.addEventListener('mousemove', onMouseMove);
131
window.addEventListener('resize', onWindowResize);
132
loop();
133
}
134
135
return () => {
136
if (canvasRef.current) {
137
canvasRef.current.remove();
138
}
139
if (animationFrameRef.current) {
140
cancelAnimationFrame(animationFrameRef.current);
141
}
142
targetElement.removeEventListener('mousemove', onMouseMove);
143
window.removeEventListener('resize', onWindowResize);
144
};
145
}, [element, particles, rate, baseImageSrc]);
146
147
return null;
148
};
149
150
export default TrailingCursor;
151

Props

PropTypeDefaultDescription
elementHTMLElementundefinedThe HTML element to which the trailing cursor effect is applied.
particlesnumber15The number of particles in the trailing cursor effect.
ratenumber0.4The rate of trailing cursor movement.
baseImageSrcstring'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAATCAYAAACk9eypAAA...' (truncated)Base64 image string used as the default particle for the cursor trail.