Docs

Snowflake Cursor

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

Installation

Install dependencies

npm install framer-motion lucide-react

Run the following command

It will create a new file snowflake-cursor.tsx inside the components/mage-ui/cursor-effects directory.

mkdir -p components/mage-ui/cursor-effects && touch components/mage-ui/cursor-effects/snowflake-cursor.tsx

Paste the code

Open the newly created file and paste the following code:

'use client';
 
import React, { useEffect, useRef } from 'react';
 
interface SnowflakeCursorOptions {
  element?: HTMLElement;
}
 
const SnowflakeCursor: React.FC<SnowflakeCursorOptions> = ({ element }) => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const particles = useRef<any[]>([]);
  const canvImages = useRef<HTMLCanvasElement[]>([]);
  const animationFrame = useRef<number | null>(null);
  const possibleEmoji = ['❄️'];
  const prefersReducedMotion = useRef<MediaQueryList | null>(null);
 
  useEffect(() => {
    // Check if window is defined (to ensure code runs on client-side)
    if (typeof window === 'undefined') return;
 
    prefersReducedMotion.current = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    );
 
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    if (!context) return;
 
    const targetElement = element || document.body;
 
    canvas.style.position = element ? 'absolute' : 'fixed';
    canvas.style.top = '0';
    canvas.style.left = '0';
    canvas.style.pointerEvents = 'none';
    canvas.style.height = '100vh'; // Set canvas height to full viewport height
 
    targetElement.appendChild(canvas);
    canvasRef.current = canvas;
 
    const setCanvasSize = () => {
      canvas.width = element ? targetElement.clientWidth : window.innerWidth;
      canvas.height = element ? targetElement.clientHeight : window.innerHeight;
    };
 
    const createEmojiImages = () => {
      context.font = '12px serif';
      context.textBaseline = 'middle';
      context.textAlign = 'center';
 
      possibleEmoji.forEach((emoji) => {
        const measurements = context.measureText(emoji);
        const bgCanvas = document.createElement('canvas');
        const bgContext = bgCanvas.getContext('2d');
        if (!bgContext) return;
 
        bgCanvas.width = measurements.width;
        bgCanvas.height = measurements.actualBoundingBoxAscent * 2;
 
        bgContext.textAlign = 'center';
        bgContext.font = '12px serif';
        bgContext.textBaseline = 'middle';
        bgContext.fillText(
          emoji,
          bgCanvas.width / 2,
          measurements.actualBoundingBoxAscent
        );
 
        canvImages.current.push(bgCanvas);
      });
    };
 
    const addParticle = (x: number, y: number) => {
      const randomImage =
        canvImages.current[
        Math.floor(Math.random() * canvImages.current.length)
        ];
      particles.current.push(new Particle(x, y, randomImage));
    };
 
    const onMouseMove = (e: MouseEvent) => {
      const x = element
        ? e.clientX - targetElement.getBoundingClientRect().left
        : e.clientX;
      const y = element
        ? e.clientY - targetElement.getBoundingClientRect().top
        : e.clientY;
      addParticle(x, y);
    };
 
    const updateParticles = () => {
      if (!context || !canvas) return;
 
      context.clearRect(0, 0, canvas.width, canvas.height);
 
      particles.current.forEach((particle, index) => {
        particle.update(context);
        if (particle.lifeSpan < 0) {
          particles.current.splice(index, 1);
        }
      });
    };
 
    const animationLoop = () => {
      updateParticles();
      animationFrame.current = requestAnimationFrame(animationLoop);
    };
 
    const init = () => {
      if (prefersReducedMotion.current?.matches) return;
 
      setCanvasSize();
      createEmojiImages();
 
      targetElement.addEventListener('mousemove', onMouseMove);
      window.addEventListener('resize', setCanvasSize);
 
      animationLoop();
    };
 
    const destroy = () => {
      if (canvasRef.current) {
        canvasRef.current.remove();
      }
      if (animationFrame.current) {
        cancelAnimationFrame(animationFrame.current);
      }
      targetElement.removeEventListener('mousemove', onMouseMove);
      window.removeEventListener('resize', setCanvasSize);
    };
 
    prefersReducedMotion.current.onchange = () => {
      if (prefersReducedMotion.current?.matches) {
        destroy();
      } else {
        init();
      }
    };
 
    init();
    return () => destroy();
  }, [element]);
 
  return (
    <div className="h-screen w-full">
      {/* Canvas is created and managed by useEffect */}
    </div>
  );
};
 
/**
 * Particle Class
 */
class Particle {
  position: { x: number; y: number };
  velocity: { x: number; y: number };
  lifeSpan: number;
  initialLifeSpan: number;
  canv: HTMLCanvasElement;
 
  constructor(x: number, y: number, canvasItem: HTMLCanvasElement) {
    this.position = { x, y };
    this.velocity = {
      x: (Math.random() < 0.5 ? -1 : 1) * (Math.random() / 2),
      y: 1 + Math.random(),
    };
    this.lifeSpan = Math.floor(Math.random() * 60 + 80);
    this.initialLifeSpan = this.lifeSpan;
    this.canv = canvasItem;
  }
 
  update(context: CanvasRenderingContext2D) {
    this.position.x += this.velocity.x;
    this.position.y += this.velocity.y;
    this.lifeSpan--;
 
    this.velocity.x += ((Math.random() < 0.5 ? -1 : 1) * 2) / 75;
    this.velocity.y -= Math.random() / 300;
 
    const scale = Math.max(this.lifeSpan / this.initialLifeSpan, 0);
 
    context.save();
    context.translate(this.position.x, this.position.y);
    context.scale(scale, scale);
    context.drawImage(this.canv, -this.canv.width / 2, -this.canv.height / 2);
    context.restore();
  }
}
 
export default SnowflakeCursor;