Snowflake Cursor Effect

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

1
'use client';
2
3
import React, { useEffect, useRef } from 'react';
4
5
interface SnowflakeCursorOptions {
6
element?: HTMLElement;
7
zIndex?: number;
8
}
9
10
const SnowflakeCursor: React.FC<SnowflakeCursorOptions> = ({ element, zIndex }) => {
11
const canvasRef = useRef<HTMLCanvasElement | null>(null);
12
const particles = useRef<any[]>([]);
13
const canvImages = useRef<HTMLCanvasElement[]>([]);
14
const animationFrame = useRef<number | null>(null);
15
const possibleEmoji = ['❄️'];
16
const prefersReducedMotion = useRef<MediaQueryList | null>(null);
17
18
useEffect(() => {
19
// Check if window is defined (to ensure code runs on client-side)
20
if (typeof window === 'undefined') return;
21
22
prefersReducedMotion.current = window.matchMedia(
23
'(prefers-reduced-motion: reduce)'
24
);
25
26
const canvas = document.createElement('canvas');
27
const context = canvas.getContext('2d');
28
if (!context) return;
29
30
const targetElement = element || document.body;
31
32
canvas.style.position = element ? 'absolute' : 'fixed';
33
canvas.style.top = '0';
34
canvas.style.left = '0';
35
canvas.style.pointerEvents = 'none';
36
canvas.style.zIndex = zIndex ? zIndex.toString() : '';
37
38
targetElement.appendChild(canvas);
39
canvasRef.current = canvas;
40
41
const setCanvasSize = () => {
42
canvas.width = element ? targetElement.clientWidth : window.innerWidth;
43
canvas.height = element ? targetElement.clientHeight : window.innerHeight;
44
};
45
46
const createEmojiImages = () => {
47
context.font = '12px serif';
48
context.textBaseline = 'middle';
49
context.textAlign = 'center';
50
51
possibleEmoji.forEach((emoji) => {
52
const measurements = context.measureText(emoji);
53
const bgCanvas = document.createElement('canvas');
54
const bgContext = bgCanvas.getContext('2d');
55
if (!bgContext) return;
56
57
bgCanvas.width = measurements.width;
58
bgCanvas.height = measurements.actualBoundingBoxAscent * 2;
59
60
bgContext.textAlign = 'center';
61
bgContext.font = '12px serif';
62
bgContext.textBaseline = 'middle';
63
bgContext.fillText(
64
emoji,
65
bgCanvas.width / 2,
66
measurements.actualBoundingBoxAscent
67
);
68
69
canvImages.current.push(bgCanvas);
70
});
71
};
72
73
const addParticle = (x: number, y: number) => {
74
const randomImage =
75
canvImages.current[
76
Math.floor(Math.random() * canvImages.current.length)
77
];
78
particles.current.push(new Particle(x, y, randomImage));
79
};
80
81
const onMouseMove = (e: MouseEvent) => {
82
const x = element
83
? e.clientX - targetElement.getBoundingClientRect().left
84
: e.clientX;
85
const y = element
86
? e.clientY - targetElement.getBoundingClientRect().top
87
: e.clientY;
88
addParticle(x, y);
89
};
90
91
const updateParticles = () => {
92
if (!context || !canvas) return;
93
94
context.clearRect(0, 0, canvas.width, canvas.height);
95
96
particles.current.forEach((particle, index) => {
97
particle.update(context);
98
if (particle.lifeSpan < 0) {
99
particles.current.splice(index, 1);
100
}
101
});
102
};
103
104
const animationLoop = () => {
105
updateParticles();
106
animationFrame.current = requestAnimationFrame(animationLoop);
107
};
108
109
const init = () => {
110
if (prefersReducedMotion.current?.matches) return;
111
112
setCanvasSize();
113
createEmojiImages();
114
115
targetElement.addEventListener('mousemove', onMouseMove);
116
window.addEventListener('resize', setCanvasSize);
117
118
animationLoop();
119
};
120
121
const destroy = () => {
122
if (canvasRef.current) {
123
canvasRef.current.remove();
124
}
125
if (animationFrame.current) {
126
cancelAnimationFrame(animationFrame.current);
127
}
128
targetElement.removeEventListener('mousemove', onMouseMove);
129
window.removeEventListener('resize', setCanvasSize);
130
};
131
132
prefersReducedMotion.current.onchange = () => {
133
if (prefersReducedMotion.current?.matches) {
134
destroy();
135
} else {
136
init();
137
}
138
};
139
140
init();
141
return () => destroy();
142
}, [element]);
143
144
return null;
145
};
146
147
/**
148
* Particle Class
149
*/
150
class Particle {
151
position: { x: number; y: number };
152
velocity: { x: number; y: number };
153
lifeSpan: number;
154
initialLifeSpan: number;
155
canv: HTMLCanvasElement;
156
157
constructor(x: number, y: number, canvasItem: HTMLCanvasElement) {
158
this.position = { x, y };
159
this.velocity = {
160
x: (Math.random() < 0.5 ? -1 : 1) * (Math.random() / 2),
161
y: 1 + Math.random(),
162
};
163
this.lifeSpan = Math.floor(Math.random() * 60 + 80);
164
this.initialLifeSpan = this.lifeSpan;
165
this.canv = canvasItem;
166
}
167
168
update(context: CanvasRenderingContext2D) {
169
this.position.x += this.velocity.x;
170
this.position.y += this.velocity.y;
171
this.lifeSpan--;
172
173
this.velocity.x += ((Math.random() < 0.5 ? -1 : 1) * 2) / 75;
174
this.velocity.y -= Math.random() / 300;
175
176
const scale = Math.max(this.lifeSpan / this.initialLifeSpan, 0);
177
178
context.save();
179
context.translate(this.position.x, this.position.y);
180
context.scale(scale, scale);
181
context.drawImage(this.canv, -this.canv.width / 2, -this.canv.height / 2);
182
context.restore();
183
}
184
}
185
186
export default SnowflakeCursor;
187