Cursor

Create custom animated cursors with interactive motion effects.

Basic Cursor
Advanced Cursor
Spring Cursor

Code

"use client";

import {
  AnimatePresence,
  type HTMLMotionProps,
  type MotionValue,
  motion,
  useMotionValue,
  useSpring,
} from "motion/react";

import React, {
  Children,
  ReactNode,
  createContext,
  forwardRef,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";

import { cn } from "@/lib/utils";

interface CursorContextProps {
  x: MotionValue<number>;
  y: MotionValue<number>;
  isInside: boolean;
  isClicking: boolean;
}

interface CursorProps {
  children: ReactNode;
  spring?: boolean;
}

interface CursorPointerProps extends HTMLMotionProps<"div"> {
  className?: string;
  children: ReactNode;
}

interface CursorBodyProps extends HTMLMotionProps<"div"> {
  children: ReactNode;
}

const CursorContext = createContext<CursorContextProps | null>(null);

const useCursor = () => {
  const context = useContext(CursorContext);

  if (!context) {
    throw new Error("Cursor components must be used within a Cursor Provider");
  }
  return context;
};

const Cursor = ({ children, spring = false }: CursorProps) => {
  const [isClicking, setIsClicking] = useState(false);

  const rawX = useMotionValue(0);
  const rawY = useMotionValue(0);

  const springX = useSpring(rawX, { stiffness: 300, damping: 30 });
  const springY = useSpring(rawY, { stiffness: 300, damping: 30 });

  const x = spring ? springX : rawX;
  const y = spring ? springY : rawY;

  const containerRef = useRef<HTMLDivElement>(null);
  const [isInside, setIsInside] = useState(false);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleMove = (e: MouseEvent) => {
      const rect = container.getBoundingClientRect();
      const insideX = e.clientX >= rect.left && e.clientX <= rect.right;
      const insideY = e.clientY >= rect.top && e.clientY <= rect.bottom;

      if (insideX && insideY) {
        setIsInside(true);
        rawX.set(e.clientX - rect.left);
        rawY.set(e.clientY - rect.top);
      } else {
        setIsInside(false);
      }
    };

    const handleClickDown = () => setIsClicking(true);
    const handleClickUp = () => setIsClicking(false);

    window.addEventListener("mousemove", handleMove);
    window.addEventListener("mousedown", handleClickDown);
    window.addEventListener("mouseup", handleClickUp);

    return () => {
      window.removeEventListener("mousemove", handleMove);
      window.removeEventListener("mousedown", handleClickDown);
      window.removeEventListener("mouseup", handleClickUp);
    };
  }, [rawX, rawY]);

  return (
    <div ref={containerRef} className="pointer-events-none absolute inset-0">
      <CursorContext.Provider value={{ x, y, isInside, isClicking }}>
        <AnimatePresence>{isInside ? <>{children}</> : null}</AnimatePresence>
      </CursorContext.Provider>
    </div>
  );
};
Cursor.displayName = "Cursor";

const CursorPointer = forwardRef<HTMLDivElement, CursorPointerProps>(
  ({ className, children, ...props }, ref) => {
    const { x, y, isClicking } = useCursor();

    return (
      <motion.div
        ref={ref}
        className={cn("absolute", className)}
        style={{ x, y, translateX: "-50%", translateY: "-50%" }}
        initial={{ opacity: 0, scale: 0.8 }}
        animate={{ opacity: 1, scale: isClicking ? 0.8 : 1 }}
        exit={{ opacity: 0, scale: 0.8 }}
        transition={{ duration: 0.2 }}
        {...props}
      >
        {children}
      </motion.div>
    );
  },
);
CursorPointer.displayName = "CursorPointer";

const CursorBody = forwardRef<HTMLDivElement, CursorBodyProps>(
  ({ className, children, ...props }, ref) => {
    const { x, y } = useCursor();

    return (
      <motion.div
        ref={ref}
        className={cn(
          "absolute flex flex-col text-xs font-medium whitespace-nowrap",
          Children.count(children) > 1
            ? "rounded-xl rounded-tl pt-1 pr-3 pb-1.5 pl-2.5 [&>:first-child]:opacity-70"
            : "rounded-full px-2.5 py-1.5",
          "bg-primary text-primary-foreground",
          className,
        )}
        style={{
          x,
          y,
          translateX: "10px",
          translateY: "10px",
        }}
        initial={{ opacity: 0, scale: 0.9 }}
        animate={{ opacity: 1, scale: 1 }}
        exit={{ opacity: 0, scale: 0.9 }}
        transition={{ duration: 0.2 }}
        {...props}
      >
        {children}
      </motion.div>
    );
  },
);
CursorBody.displayName = "CursorBody";

export { Cursor, CursorPointer, CursorBody };