Cursor ImageTrail

PreviousNext

mouse trail component with animated image effects on hover.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/cursorimagetrail.tsx
"use client";

import React, { useRef, ReactNode, MouseEventHandler } from "react";
import { motion, useAnimate } from "framer-motion";

interface CursorImageTrailProps {
  children: ReactNode;
  images: string[];
  renderImageBuffer: number;
  rotationRange: number;
  animationStyle?: "dynamic" | "minimal";
}

const CursorImageTrail = ({
  children,
  images,
  renderImageBuffer,
  rotationRange,
  animationStyle = "dynamic",
}: CursorImageTrailProps) => {
  const [scope, animate] = useAnimate();
  const lastRenderPosition = useRef({ x: 0, y: 0 });
  const imageRenderCount = useRef(0);

  const calculateDistance = (
    x1: number,
    y1: number,
    x2: number,
    y2: number
  ) => {
    const deltaX = x2 - x1;
    const deltaY = y2 - y1;
    return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
  };

  const renderNextImage = () => {
    const index = imageRenderCount.current % images.length;
    const selector = `[data-mouse-move-index="${index}"]`;
    const el = document.querySelector(selector) as HTMLElement;
    if (!el || !scope.current) return;

    const containerRect = scope.current.getBoundingClientRect();
    const relativeX = lastRenderPosition.current.x - containerRect.left;
    const relativeY = lastRenderPosition.current.y - containerRect.top;

    el.style.left = `${relativeX}px`;
    el.style.top = `${relativeY}px`;
    el.style.zIndex = `${imageRenderCount.current}`;

    const rotation = Math.random() * rotationRange;

    if (animationStyle === "dynamic") {
      const scale = 0.8 + Math.random() * 0.4;
      const skewX = Math.random() * 10;
      const skewY = Math.random() * 10;

      animate(
        selector,
        {
          opacity: [0, 1],
          transform: [
            `translate(-50%, -30%) scale(${scale}) rotate(${
              index % 2 === 0 ? rotation : -rotation
            }deg) skew(${skewX}deg, ${skewY}deg)`,
            `translate(-50%, -50%) scale(1.1) rotate(${
              index % 2 === 0 ? -rotation : rotation
            }deg) skew(0deg, 0deg)`,
          ],
        },
        { type: "spring", stiffness: 150, damping: 20 }
      );

      animate(
        selector,
        {
          opacity: [1, 0],
          y: ["0%", "-40%"],
        },
        {
          duration: 0.8,
          ease: "easeOut",
          delay: 2,
        }
      );
    } else {
      animate(
        selector,
        {
          opacity: [0, 1],
          transform: [
            `translate(-50%, -30%) scale(0.5) rotate(${
              index % 2 === 0 ? rotation : -rotation
            }deg)`,
            `translate(-50%, -50%) scale(1.2) rotate(${
              index % 2 === 0 ? -rotation : rotation
            }deg)`,
          ],
        },
        { type: "spring", stiffness: 150, damping: 20 }
      );

      animate(
        selector,
        {
          opacity: [1, 0],
          y: ["0%", "-40%"],
        },
        {
          duration: 0.8,
          ease: "easeOut",
          delay: 2,
        }
      );
    }

    imageRenderCount.current += 1;
  };

  const handleMouseMove: MouseEventHandler<HTMLDivElement> = (e) => {
    const { clientX, clientY } = e;
    const distance = calculateDistance(
      clientX,
      clientY,
      lastRenderPosition.current.x,
      lastRenderPosition.current.y
    );
    if (distance >= renderImageBuffer) {
      lastRenderPosition.current = { x: clientX, y: clientY };
      renderNextImage();
    }
  };

  return (
    <div
      ref={scope as React.RefObject<HTMLDivElement>}
      className="relative w-full h-full overflow-hidden"
      onMouseMove={handleMouseMove}
    >
      {children}

      {images.map((img, index) => (
        <motion.img
          key={index}
          data-mouse-move-index={index}
          src={img}
          alt={`Mouse move image ${index}`}
          className={
            animationStyle === "dynamic"
              ? "pointer-events-none absolute h-48 w-auto rounded-xl border border-black/30 bg-white/70 dark:border-white/20 dark:bg-neutral-900 opacity-0 shadow-md transition-all duration-300"
              : "pointer-events-none absolute h-48 w-auto rounded-xl border border-black bg-neutral-100 opacity-0 dark:border-neutral-800 dark:bg-neutral-900 backdrop-blur-md saturate-150"
          }
          initial={false}
        />
      ))}
    </div>
  );
};

export default CursorImageTrail;

Installation

npx shadcn@latest add @scrollxui/cursorimagetrail

Usage

import { Cursorimagetrail } from "@/components/cursorimagetrail"
<Cursorimagetrail />