Mouse Trail

An interactive mouse trail component that creates a stunning visual effect with images following the mouse cursor. Features customizable images, stagger timing, and smooth GSAP animations.

React
GSAP
Interactive
Animation
Mouse Effects
Loading images...

Move Your Mouse
To See Magic

Using CLI

npx dimaac add MouseTrail

Manual Installation

npm install react @gsap/react

lib/utils.ts

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
} 

components/ui/MouseTrail.tsx

'use client';

import React, { useRef, useState, useEffect } from 'react';
import { gsap } from 'gsap';
import { useGSAP } from '@gsap/react';
import { cn } from '@/lib/utils';

interface MouseTrailProps {
  images: string[];
  containerClassName?: string;
  imageClassName?: string;
  imageWidth?: number;
  imageHeight?: number;
  stagger?: number;
  duration?: number;
  ease?: string;
}

const MouseTrail: React.FC<MouseTrailProps> = ({
  images,
  containerClassName,
  imageClassName,
  imageWidth = 288,
  imageHeight = 384,
  stagger = 0.1,
  duration = 0.3,
  ease = "power2.out"
}) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [imagesLoaded, setImagesLoaded] = useState(false);
  const hasMovedRef = useRef(false);
  const containerBoundsRef = useRef<DOMRect | null>(null);
  const rafIdRef = useRef<number | null>(null);

  // Preload images to prevent lag
  useEffect(() => {
    const preloadImages = async () => {
      const imagePromises = images.map((src) => {
        return new Promise<void>((resolve, reject) => {
          const img = new Image();
          img.onload = () => resolve();
          img.onerror = reject;
          img.src = src;
        });
      });

      try {
        await Promise.all(imagePromises);
        setImagesLoaded(true);
      } catch (error) {
        console.warn('Some images failed to preload:', error);
        // Still set to true to allow component to function
        setImagesLoaded(true);
      }
    };

    preloadImages();
  }, [images]);

  useGSAP(() => {
    if (!containerRef.current || !imagesLoaded) return;

    // Cache container bounds
    const updateBounds = () => {
      if (containerRef.current) {
        containerBoundsRef.current = containerRef.current.getBoundingClientRect();
      }
    };

    // Initialize images with better performance
    gsap.set(".trail-img", {
      x: -imageWidth, // Start offscreen to avoid flash
      y: -imageHeight,
      opacity: 0,
      scale: 1,
      force3D: true, // Enable hardware acceleration
      willChange: "transform, opacity"
    });

    // Update bounds initially and on resize/scroll with debouncing
    updateBounds();
    
    let resizeTimeout: NodeJS.Timeout;
    const debouncedUpdateBounds = () => {
      clearTimeout(resizeTimeout);
      resizeTimeout = setTimeout(updateBounds, 16); // ~60fps
    };

    window.addEventListener('resize', debouncedUpdateBounds);
    window.addEventListener('scroll', debouncedUpdateBounds, { passive: true });

    const handleMouseMove = (e: MouseEvent) => {
      if (!containerRef.current || !containerBoundsRef.current) return;
      
      // Cancel previous animation frame
      if (rafIdRef.current) {
        cancelAnimationFrame(rafIdRef.current);
      }

      // Use requestAnimationFrame for smoother performance
      rafIdRef.current = requestAnimationFrame(() => {
        if (!containerRef.current || !containerBoundsRef.current) return;

        
        const containerRect = containerBoundsRef.current;
        const relativeX = e.clientX - containerRect.left;
        const relativeY = e.clientY - containerRect.top;
        
        const isWithinBounds = 
          relativeX >= 0 && 
          relativeX <= containerRect.width && 
          relativeY >= 0 && 
          relativeY <= containerRect.height;
        
        if (!hasMovedRef.current && isWithinBounds) {
          hasMovedRef.current = true;
        }
        
        // Use higher performance animation settings
        gsap.to(".trail-img", {
          x: relativeX - imageWidth / 2,
          y: relativeY - imageHeight / 2,
          opacity: hasMovedRef.current && isWithinBounds ? 1 : 0,
          stagger: stagger,
          duration: duration,
          ease: ease,
          force3D: true,
          overwrite: "auto" // Prevent animation conflicts
        });
      });
    };

    const handleMouseLeave = () => {
      if (rafIdRef.current) {
        cancelAnimationFrame(rafIdRef.current);
      }
      
      gsap.to(".trail-img", {
        opacity: 0,
        duration: 0.3,
        ease: "power2.out",
        force3D: true
      });
      hasMovedRef.current = false;
    };

    document.addEventListener('mousemove', handleMouseMove, { passive: true });
    document.addEventListener('mouseleave', handleMouseLeave);

    // Cleanup
    return () => {
      if (rafIdRef.current) {
        cancelAnimationFrame(rafIdRef.current);
      }
      clearTimeout(resizeTimeout);
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseleave', handleMouseLeave);
      window.removeEventListener('resize', debouncedUpdateBounds);
      window.removeEventListener('scroll', debouncedUpdateBounds);
    };
  }, { scope: containerRef, dependencies: [stagger, duration, ease, imageWidth, imageHeight, imagesLoaded] });

  return (
    <div 
      ref={containerRef}
      className={cn(
        "w-full h-full overflow-hidden relative z-10",
        containerClassName
      )}
      style={{
        // Optimize for animations
        willChange: "transform",
        transform: "translateZ(0)" // Force hardware acceleration
      }}
    >
      {/* Loading indicator */}
      {!imagesLoaded && (
        <div className="absolute inset-0 flex items-center justify-center">
          <div className="text-gray-500 text-lg">Loading images...</div>
        </div>
      )}
      
      {imagesLoaded && images.map((src, index) => (
        <div
          key={index}
          className={cn(
            "trail-img absolute pointer-events-none",
            imageClassName
          )}
          style={{
            width: `${imageWidth}px`,
            height: `${imageHeight}px`,
            zIndex: images.length - index,
            willChange: "transform, opacity",
            transform: "translateZ(0)" // Force hardware acceleration
          }}
        >
          <img
            src={src}
            alt={`Trail image ${index + 1}`}
            className="w-full h-full object-cover block rounded-lg shadow-lg"
            draggable={false}
            style={{
              // Optimize image rendering
              transform: "translateZ(0)"
            }}
          />
        </div>
      ))}
      
    </div>
  );
};

export default MouseTrail;