Docs

Rainbow Cursor Effect

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 rainbow-cursor-effect.tsx inside the components/mage-ui/cursor-effects directory.

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

Paste the code

Open the newly created file and paste the following code:

// @ts-nocheck
'use client';
 
import React, { useEffect, useRef } from 'react';
 
interface RainbowCursorProps {
  element?: HTMLElement;
  length?: number;
  colors?: string[];
  size?: number;
  trailSpeed?: number;
  colorCycleSpeed?: number;
  blur?: number;
  pulseSpeed?: number;
  pulseMin?: number;
  pulseMax?: number;
}
 
const RainbowCursor: React.FC<RainbowCursorProps> = ({
  element,
  length = 20,
  colors = ['#FE0000', '#FD8C00', '#FFE500', '#119F0B', '#0644B3', '#C22EDC'],
  size = 3,
  trailSpeed = 0.4,
  colorCycleSpeed = 0.002,
  blur = 0,
  pulseSpeed = 0.01,
  pulseMin = 0.8,
  pulseMax = 1.2,
}) => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const contextRef = useRef<CanvasRenderingContext2D | null>(null);
  const cursorRef = useRef({ x: 0, y: 0 });
  const particlesRef = useRef<Array<{ position: { x: number; y: number } }>>(
    []
  );
  const animationFrameRef = useRef<number>();
  const cursorsInittedRef = useRef(false);
  const timeRef = useRef(0);
 
  class Particle {
    position: { x: number; y: number };
 
    constructor(x: number, y: number) {
      this.position = { x, y };
    }
  }
 
  // Helper function to interpolate between colors
  const interpolateColors = (
    color1: string,
    color2: string,
    factor: number
  ) => {
    const r1 = parseInt(color1.substr(1, 2), 16);
    const g1 = parseInt(color1.substr(3, 2), 16);
    const b1 = parseInt(color1.substr(5, 2), 16);
 
    const r2 = parseInt(color2.substr(1, 2), 16);
    const g2 = parseInt(color2.substr(3, 2), 16);
    const b2 = parseInt(color2.substr(5, 2), 16);
 
    const r = Math.round(r1 + (r2 - r1) * factor);
    const g = Math.round(g1 + (g2 - g1) * factor);
    const b = Math.round(b1 + (b2 - b1) * factor);
 
    return `rgb(${r}, ${g}, ${b})`;
  };
 
  // Function to get dynamic size based on pulse
  const getPulseSize = (baseSize: number, time: number) => {
    const pulse = Math.sin(time * pulseSpeed);
    const scaleFactor = pulseMin + ((pulse + 1) * (pulseMax - pulseMin)) / 2;
    return baseSize * scaleFactor;
  };
 
  useEffect(() => {
    const hasWrapperEl = element !== undefined;
    const targetElement = hasWrapperEl ? element : document.body;
 
    const prefersReducedMotion = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    );
 
    if (prefersReducedMotion.matches) {
      console.log('Reduced motion is enabled - cursor animation disabled');
      return;
    }
 
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d', { alpha: true });
 
    if (!context) return;
 
    canvasRef.current = canvas;
    contextRef.current = context;
 
    canvas.style.top = '0px';
    canvas.style.left = '0px';
    canvas.style.pointerEvents = 'none';
    canvas.style.position = hasWrapperEl ? 'absolute' : 'fixed';
    canvas.style.height = '100vh'; // Set canvas height to full viewport height
 
    if (hasWrapperEl) {
      element?.appendChild(canvas);
      canvas.width = element.clientWidth;
      canvas.height = element.clientHeight;
    } else {
      document.body.appendChild(canvas);
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    }
 
    const onMouseMove = (e: MouseEvent) => {
      if (hasWrapperEl && element) {
        const boundingRect = element.getBoundingClientRect();
        cursorRef.current.x = e.clientX - boundingRect.left;
        cursorRef.current.y = e.clientY - boundingRect.top;
      } else {
        cursorRef.current.x = e.clientX;
        cursorRef.current.y = e.clientY;
      }
 
      if (!cursorsInittedRef.current) {
        cursorsInittedRef.current = true;
        for (let i = 0; i < length; i++) {
          particlesRef.current.push(
            new Particle(cursorRef.current.x, cursorRef.current.y)
          );
        }
      }
    };
 
    const onWindowResize = () => {
      if (hasWrapperEl && element) {
        canvas.width = element.clientWidth;
        canvas.height = element.clientHeight;
      } else {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
      }
    };
 
    const updateParticles = () => {
      if (!contextRef.current || !canvasRef.current) return;
 
      const ctx = contextRef.current;
      const canvas = canvasRef.current;
 
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.lineJoin = 'round';
 
      if (blur > 0) {
        ctx.filter = `blur(${blur}px)`;
      }
 
      const particleSets = [];
      let x = cursorRef.current.x;
      let y = cursorRef.current.y;
 
      particlesRef.current.forEach((particle, index) => {
        const nextParticle =
          particlesRef.current[index + 1] || particlesRef.current[0];
 
        particle.position.x = x;
        particle.position.y = y;
 
        particleSets.push({ x, y });
 
        x += (nextParticle.position.x - particle.position.x) * trailSpeed;
        y += (nextParticle.position.y - particle.position.y) * trailSpeed;
      });
 
      // Time-based color cycling
      timeRef.current += colorCycleSpeed;
      const colorOffset = timeRef.current % 1;
 
      // Dynamic size based on pulse
      const currentSize = getPulseSize(size, timeRef.current);
 
      colors.forEach((color, index) => {
        const nextColor = colors[(index + 1) % colors.length];
 
        ctx.beginPath();
        ctx.strokeStyle = interpolateColors(
          color,
          nextColor,
          (index + colorOffset) / colors.length
        );
 
        if (particleSets.length) {
          ctx.moveTo(
            particleSets[0].x,
            particleSets[0].y + index * (currentSize - 1)
          );
        }
 
        particleSets.forEach((set, particleIndex) => {
          if (particleIndex !== 0) {
            ctx.lineTo(set.x, set.y + index * currentSize);
          }
        });
 
        ctx.lineWidth = currentSize;
        ctx.lineCap = 'round';
        ctx.stroke();
      });
    };
 
    const loop = () => {
      updateParticles();
      animationFrameRef.current = requestAnimationFrame(loop);
    };
 
    targetElement.addEventListener('mousemove', onMouseMove);
    window.addEventListener('resize', onWindowResize);
    loop();
 
    return () => {
      if (canvasRef.current) {
        canvasRef.current.remove();
      }
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }
      targetElement.removeEventListener('mousemove', onMouseMove);
      window.removeEventListener('resize', onWindowResize);
    };
  }, [
    element,
    length,
    colors,
    size,
    trailSpeed,
    colorCycleSpeed,
    blur,
    pulseSpeed,
    pulseMin,
    pulseMax,
  ]);
 
  return (
    <div className="h-screen w-full">
      {/* Canvas is created dynamically in the useEffect */}
    </div>
  );
};
 
export default RainbowCursor;