VeloxUI
interaction·free

Cursor Follower

Lagging custom cursor that grows over interactive elements.

Installation

terminal
pnpm add gsap @gsap/react

Then copy the component below into src/components/motion/cursor-follower.tsx. It expects @/lib/gsap (plugin registration) and @/lib/utils (cn).

Source

src/components/motion/cursor-follower.tsx
"use client";

import { useRef } from "react";
import { gsap, useGSAP } from "@/lib/gsap";
import { cn } from "@/lib/utils";

export interface CursorFollowerProps {
  size?: number;
  className?: string;
  /** Lag in seconds. */
  lag?: number;
  /** CSS selector; hovering matching elements grows the cursor. */
  hoverSelector?: string;
  hoverScale?: number;
}

export function CursorFollower({
  size = 24,
  className,
  lag = 0.25,
  hoverSelector = "a, button, [data-cursor]",
  hoverScale = 3,
}: CursorFollowerProps) {
  const ref = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      const el = ref.current;
      if (!el) return;
      if (window.matchMedia("(pointer: coarse)").matches) return;
      gsap.set(el, { xPercent: -50, yPercent: -50, opacity: 0 });
      const x = gsap.quickTo(el, "x", { duration: lag, ease: "power3" });
      const y = gsap.quickTo(el, "y", { duration: lag, ease: "power3" });
      const move = (e: MouseEvent) => {
        x(e.clientX);
        y(e.clientY);
        gsap.to(el, { opacity: 1, duration: 0.3 });
      };
      const over = (e: MouseEvent) => {
        const t = (e.target as Element).closest?.(hoverSelector);
        gsap.to(el, { scale: t ? hoverScale : 1, duration: 0.35, ease: "power3.out" });
      };
      window.addEventListener("mousemove", move);
      document.addEventListener("mouseover", over);
      return () => {
        window.removeEventListener("mousemove", move);
        document.removeEventListener("mouseover", over);
      };
    },
    { scope: ref, dependencies: [lag, hoverSelector, hoverScale] }
  );

  return (
    <div
      ref={ref}
      aria-hidden
      className={cn("pointer-events-none fixed left-0 top-0 z-[9999] rounded-full mix-blend-difference bg-white", className)}
      style={{ width: size, height: size }}
    />
  );
}