Docs

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

mkdir -p components/mage-ui/cursor-effects && touch components/mage-ui/cursor-effects/character-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 Particle {
  rotationSign: number;
  age: number;
  initialLifeSpan: number;
  lifeSpan: number;
  velocity: { x: number; y: number };
  position: { x: number; y: number };
  canv: HTMLCanvasElement;
  update: (context: CanvasRenderingContext2D) => void;
}
 
interface CharacterCursorProps {
  characters?: string[];
  colors?: string[];
  cursorOffset?: { x: number; y: number };
  font?: string;
  characterLifeSpanFunction?: () => number;
  initialCharacterVelocityFunction?: () => { x: number; y: number };
  characterVelocityChangeFunctions?: {
    x_func: (age: number, lifeSpan: number) => number;
    y_func: (age: number, lifeSpan: number) => number;
  };
  characterScalingFunction?: (age: number, lifeSpan: number) => number;
  characterNewRotationDegreesFunction?: (
    age: number,
    lifeSpan: number
  ) => number;
  wrapperElement?: HTMLElement;
}
 
const CharacterCursor: React.FC<CharacterCursorProps> = ({
  characters = ['h', 'e', 'l', 'l', 'o'],
  colors = ['#6622CC', '#A755C2', '#B07C9E', '#B59194', '#D2A1B8'],
  cursorOffset = { x: 0, y: 0 },
  font = '15px serif',
  characterLifeSpanFunction = () => Math.floor(Math.random() * 60 + 80),
  initialCharacterVelocityFunction = () => ({
    x: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5,
    y: (Math.random() < 0.5 ? -1 : 1) * Math.random() * 5,
  }),
  characterVelocityChangeFunctions = {
    x_func: () => (Math.random() < 0.5 ? -1 : 1) / 30,
    y_func: () => (Math.random() < 0.5 ? -1 : 1) / 15,
  },
  characterScalingFunction = (age, lifeSpan) =>
    Math.max(((lifeSpan - age) / lifeSpan) * 2, 0),
  characterNewRotationDegreesFunction = (age, lifeSpan) => (lifeSpan - age) / 5,
  wrapperElement,
}) => {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const particlesRef = useRef<Particle[]>([]);
  const cursorRef = useRef({ x: 0, y: 0 });
  const animationFrameRef = useRef<number | null>(null);
  const canvImagesRef = useRef<HTMLCanvasElement[]>([]);
 
  useEffect(() => {
    const prefersReducedMotion = window.matchMedia(
      '(prefers-reduced-motion: reduce)'
    );
    let canvas: HTMLCanvasElement | null = null;
    let context: CanvasRenderingContext2D | null = null;
    let width = window.innerWidth;
    let height = window.innerHeight;
 
    const randomPositiveOrNegativeOne = () => (Math.random() < 0.5 ? -1 : 1);
 
    class Particle {
      rotationSign: number;
      age: number;
      initialLifeSpan: number;
      lifeSpan: number;
      velocity: { x: number; y: number };
      position: { x: number; y: number };
      canv: HTMLCanvasElement;
 
      constructor(x: number, y: number, canvasItem: HTMLCanvasElement) {
        const lifeSpan = characterLifeSpanFunction();
        this.rotationSign = randomPositiveOrNegativeOne();
        this.age = 0;
        this.initialLifeSpan = lifeSpan;
        this.lifeSpan = lifeSpan;
        this.velocity = initialCharacterVelocityFunction();
        this.position = {
          x: x + cursorOffset.x,
          y: y + cursorOffset.y,
        };
        this.canv = canvasItem;
      }
 
      update(context: CanvasRenderingContext2D) {
        this.position.x += this.velocity.x;
        this.position.y += this.velocity.y;
        this.lifeSpan--;
        this.age++;
 
        this.velocity.x += characterVelocityChangeFunctions.x_func(
          this.age,
          this.initialLifeSpan
        );
        this.velocity.y += characterVelocityChangeFunctions.y_func(
          this.age,
          this.initialLifeSpan
        );
 
        const scale = characterScalingFunction(this.age, this.initialLifeSpan);
 
        const degrees =
          this.rotationSign *
          characterNewRotationDegreesFunction(this.age, this.initialLifeSpan);
        const radians = degrees * 0.0174533;
 
        context.translate(this.position.x, this.position.y);
        context.rotate(radians);
 
        context.drawImage(
          this.canv,
          (-this.canv.width / 2) * scale,
          -this.canv.height / 2,
          this.canv.width * scale,
          this.canv.height * scale
        );
 
        context.rotate(-radians);
        context.translate(-this.position.x, -this.position.y);
      }
    }
 
    const init = () => {
      if (prefersReducedMotion.matches) {
        console.log(
          'This browser has prefers reduced motion turned on, so the cursor did not init'
        );
        return false;
      }
 
      canvas = canvasRef.current;
      if (!canvas) return;
 
      context = canvas.getContext('2d');
      if (!context) return;
 
      canvas.style.top = '0px';
      canvas.style.left = '0px';
      canvas.style.pointerEvents = 'none';
      canvas.style.height = '100vh'; // Set canvas height to full viewport height
 
      if (wrapperElement) {
        canvas.style.position = 'absolute';
        wrapperElement.appendChild(canvas);
        canvas.width = wrapperElement.clientWidth;
        canvas.height = wrapperElement.clientHeight;
      } else {
        canvas.style.position = 'fixed';
        document.body.appendChild(canvas);
        canvas.width = width;
        canvas.height = height;
      }
 
      context.font = font;
      context.textBaseline = 'middle';
      context.textAlign = 'center';
 
      characters.forEach((char) => {
        let measurements = context.measureText(char);
        let bgCanvas = document.createElement('canvas');
        let bgContext = bgCanvas.getContext('2d');
 
        if (bgContext) {
          bgCanvas.width = measurements.width;
          bgCanvas.height = measurements.actualBoundingBoxAscent * 2.5;
 
          bgContext.textAlign = 'center';
          bgContext.font = font;
          bgContext.textBaseline = 'middle';
          var randomColor = colors[Math.floor(Math.random() * colors.length)];
          bgContext.fillStyle = randomColor;
 
          bgContext.fillText(
            char,
            bgCanvas.width / 2,
            measurements.actualBoundingBoxAscent
          );
 
          canvImagesRef.current.push(bgCanvas);
        }
      });
 
      bindEvents();
      loop();
    };
 
    const bindEvents = () => {
      const element = wrapperElement || document.body;
      element.addEventListener('mousemove', onMouseMove);
      element.addEventListener('touchmove', onTouchMove, { passive: true });
      element.addEventListener('touchstart', onTouchMove, { passive: true });
      window.addEventListener('resize', onWindowResize);
    };
 
    const onWindowResize = () => {
      width = window.innerWidth;
      height = window.innerHeight;
 
      if (!canvasRef.current) return;
 
      if (wrapperElement) {
        canvasRef.current.width = wrapperElement.clientWidth;
        canvasRef.current.height = wrapperElement.clientHeight;
      } else {
        canvasRef.current.width = width;
        canvasRef.current.height = height;
      }
    };
 
    const onTouchMove = (e: TouchEvent) => {
      if (e.touches.length > 0) {
        for (let i = 0; i < e.touches.length; i++) {
          addParticle(
            e.touches[i].clientX,
            e.touches[i].clientY,
            canvImagesRef.current[
            Math.floor(Math.random() * canvImagesRef.current.length)
            ]
          );
        }
      }
    };
 
    const onMouseMove = (e: MouseEvent) => {
      if (wrapperElement) {
        const boundingRect = wrapperElement.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;
      }
 
      addParticle(
        cursorRef.current.x,
        cursorRef.current.y,
        canvImagesRef.current[Math.floor(Math.random() * characters.length)]
      );
    };
 
    const addParticle = (x: number, y: number, img: HTMLCanvasElement) => {
      particlesRef.current.push(new Particle(x, y, img));
    };
 
    const updateParticles = () => {
      if (!canvas || !context) return;
 
      if (particlesRef.current.length === 0) {
        return;
      }
 
      context.clearRect(0, 0, canvas.width, canvas.height);
 
      // Update
      for (let i = 0; i < particlesRef.current.length; i++) {
        particlesRef.current[i].update(context);
      }
 
      // Remove dead particles
      for (let i = particlesRef.current.length - 1; i >= 0; i--) {
        if (particlesRef.current[i].lifeSpan < 0) {
          particlesRef.current.splice(i, 1);
        }
      }
 
      if (particlesRef.current.length === 0) {
        context.clearRect(0, 0, canvas.width, canvas.height);
      }
    };
 
    const loop = () => {
      updateParticles();
      animationFrameRef.current = requestAnimationFrame(loop);
    };
 
    init();
 
    return () => {
      if (canvas) {
        canvas.remove();
      }
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }
      const element = wrapperElement || document.body;
      element.removeEventListener('mousemove', onMouseMove);
      element.removeEventListener('touchmove', onTouchMove);
      element.removeEventListener('touchstart', onTouchMove);
      window.removeEventListener('resize', onWindowResize);
    };
  }, [
    characters,
    colors,
    cursorOffset,
    font,
    characterLifeSpanFunction,
    initialCharacterVelocityFunction,
    characterVelocityChangeFunctions,
    characterScalingFunction,
    characterNewRotationDegreesFunction,
    wrapperElement,
  ]);
 
  return <canvas ref={canvasRef} className="h-screen" />;
};
 
export default CharacterCursor;