Docs

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

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

Paste the code

Open the newly created file and paste the following code:

'use client';
import React, { useReducer, useEffect, useRef } from 'react';
 
// Define the shape of a ripple object
interface Ripple {
  id: string;
  x: number;
  y: number;
  size: number;
  opacity: number;
}
 
// Props for the RippleCursor component
interface RippleCursorProps {
  maxSize?: number; // Maximum size of the ripple
  duration?: number; // Duration of the ripple animation in milliseconds
  blur?: boolean; // Whether the ripple has a blur effect
  color?: string; // Ripple color
}
 
// Type for the reducer's state
type RippleState = Ripple[];
 
// Type for the reducer's actions
type RippleAction =
  | { type: 'ADD_RIPPLE'; payload: Ripple }
  | { type: 'REMOVE_RIPPLE'; payload: string }
  | { type: 'UPDATE_RIPPLE'; payload: Ripple };
 
// Reducer function
const rippleReducer = (
  state: RippleState,
  action: RippleAction
): RippleState => {
  switch (action.type) {
    case 'ADD_RIPPLE':
      return [...state, action.payload].slice(-20); // Limit ripple count for performance
    case 'REMOVE_RIPPLE':
      return state.filter((ripple) => ripple.id !== action.payload);
    case 'UPDATE_RIPPLE':
      return state.map((ripple) =>
        ripple.id === action.payload.id ? action.payload : ripple
      );
    default:
      return state;
  }
};
 
// Component definition
const RippleCursor: React.FC<RippleCursorProps> = ({
  maxSize = 50,
  duration = 1000,
  blur = true,
  color = 'rgba(0, 150, 255, 0.5)',
}) => {
  const [ripples, dispatch] = useReducer(rippleReducer, []);
  const animationFrameRef = useRef<number | null>(null);
 
  // Event handler for mouse movements with throttling
  useEffect(() => {
    let lastCallTime = 0;
    const throttleInterval = 16; // Approximately 60fps
 
    const handleMouseMove = (e: MouseEvent): void => {
      const now = Date.now();
      if (now - lastCallTime < throttleInterval) return;
 
      lastCallTime = now;
      const id = `${now}-${Math.random()}`;
 
      // Create ripple at initial size 0
      const ripple: Ripple = {
        id,
        x: e.clientX,
        y: e.clientY,
        size: 0,
        opacity: 1
      };
 
      dispatch({ type: 'ADD_RIPPLE', payload: ripple });
 
      // Animate ripple expansion
      let startTime: number | null = null;
 
      const animateRipple = (timestamp: number) => {
        if (!startTime) startTime = timestamp;
        const progress = (timestamp - startTime) / duration;
 
        if (progress < 1) {
          // Update ripple size and opacity based on progress
          const updatedRipple = {
            ...ripple,
            size: maxSize * progress,
            opacity: 1 - progress
          };
 
          dispatch({ type: 'UPDATE_RIPPLE', payload: updatedRipple });
          animationFrameRef.current = requestAnimationFrame(animateRipple);
        } else {
          // Remove ripple when animation completes
          dispatch({ type: 'REMOVE_RIPPLE', payload: id });
        }
      };
 
      animationFrameRef.current = requestAnimationFrame(animateRipple);
    };
 
    window.addEventListener('mousemove', handleMouseMove);
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }
    };
  }, [duration, maxSize]);
 
  return (
    <div className="top-0 left-0 w-full h-screen pointer-events-none overflow-hidden z-[9999]">
      {ripples.map((ripple) => (
        <div
          key={ripple.id}
          className="absolute rounded-full"
          style={{
            left: `${ripple.x}px`,
            top: `${ripple.y}px`,
            width: `${ripple.size}px`,
            height: `${ripple.size}px`,
            transform: 'translate(-50%, -50%)',
            backgroundColor: color,
            boxShadow: blur ? '0 0 10px rgba(0,150,255,0.7), 0 0 20px rgba(0,150,255,0.4)' : 'none',
            filter: blur ? 'blur(4px)' : 'none',
            opacity: ripple.opacity,
            willChange: 'width, height, opacity'
          }}
        />
      ))}
    </div>
  );
};
 
export default RippleCursor;