Native Follow Cursor

PreviousNext

Label that smoothly follows the cursor with spring physics and customizable variants.

Docs
uitripledcomponent

Preview

Loading preview…
components/native/carbon/native-follow-cursor-carbon.tsx
"use client";

import type React from "react";

import { cn } from "@/lib/utils";
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useSpring,
} from "framer-motion";
import { useEffect, useRef, useState } from "react";

export interface NativeFollowCursorProps {
  /**
   * Name or text to display that follows the cursor
   * @deprecated Use children prop instead for custom content
   */
  name?: string;
  /**
   * Custom content to display (can be React nodes)
   */
  children?: React.ReactNode;
  /**
   * Size variant of the label
   * Default: "md"
   */
  size?: "sm" | "md" | "lg";
  /**
   * Visual variant
   * Default: "default"
   */
  variant?: "default" | "glass" | "solid" | "minimal";
  /**
   * Offset from cursor in pixels
   * Default: { x: 12, y: 12 }
   */
  offset?: { x: number; y: number };
  /**
   * Spring stiffness for smooth following
   * Default: 150
   */
  stiffness?: number;
  /**
   * Spring damping for smooth following
   * Default: 20
   */
  damping?: number;
  /**
   * Additional class names for the label
   */
  className?: string;
  /**
   * Show decorative dot indicator
   * Default: true (false when using custom children)
   */
  showDot?: boolean;
}

export interface NativeFollowCursorAreaProps {
  /**
   * Content area to track cursor within
   */
  children: React.ReactNode;
  /**
   * Content that follows the cursor
   */
  cursorContent: React.ReactNode;
  /**
   * Size variant of the cursor label
   * Default: "md"
   */
  size?: "sm" | "md" | "lg";
  /**
   * Visual variant
   * Default: "default"
   */
  variant?: "default" | "glass" | "solid" | "minimal";
  /**
   * Offset from cursor in pixels
   * Default: { x: 12, y: 12 }
   */
  offset?: { x: number; y: number };
  /**
   * Spring stiffness for smooth following
   * Default: 150
   */
  stiffness?: number;
  /**
   * Spring damping for smooth following
   * Default: 20
   */
  damping?: number;
  /**
   * Additional class names for the area wrapper
   */
  className?: string;
  /**
   * Additional class names for the cursor label
   */
  cursorClassName?: string;
  /**
   * Show decorative dot indicator
   * Default: false
   */
  showDot?: boolean;
}

const sizeVariants = {
  sm: "text-xs px-2 py-1",
  md: "text-sm px-3 py-1.5",
  lg: "text-base px-4 py-2",
};

const dotSizeVariants = {
  sm: "h-1.5 w-1.5",
  md: "h-2 w-2",
  lg: "h-2.5 w-2.5",
};

export function NativeFollowCursor({
  name,
  children,
  size = "md",
  variant = "default",
  offset = { x: 12, y: 12 },
  stiffness = 150,
  damping = 20,
  className,
  showDot,
}: NativeFollowCursorProps) {
  const shouldShowDot = showDot ?? (children ? false : true);
  const content = children || name;

  const cursorX = useMotionValue(0);
  const cursorY = useMotionValue(0);

  const springConfig = { stiffness, damping };
  const x = useSpring(cursorX, springConfig);
  const y = useSpring(cursorY, springConfig);

  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      cursorX.set(e.clientX + offset.x);
      cursorY.set(e.clientY + offset.y);
    };

    document.addEventListener("mousemove", handleMouseMove);

    return () => {
      document.removeEventListener("mousemove", handleMouseMove);
    };
  }, [cursorX, cursorY, offset]);

  const getVariantStyles = () => {
    switch (variant) {
      case "glass":
        return "bg-background/80 backdrop-blur-md border border-border/50 shadow-lg";
      case "solid":
        return "bg-primary text-primary-foreground shadow-md";
      case "minimal":
        return "bg-background/95 border border-border shadow-sm";
      case "default":
      default:
        return "bg-popover text-popover-foreground border border-border shadow-lg";
    }
  };

  return (
    <motion.div
      initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
      animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
      style={{
        x,
        y,
      }}
      transition={{
        opacity: { duration: 0.2 },
        scale: { duration: 0.2 },
        filter: { duration: 0.2 },
      }}
      className={cn(
        "fixed top-0 left-0 z-50 pointer-events-none select-none",
        "rounded-full flex items-center gap-2",
        sizeVariants[size],
        getVariantStyles(),
        className
      )}
    >
      {shouldShowDot && (
        <motion.div
          animate={{
            scale: [1, 1.2, 1],
            opacity: [0.7, 1, 0.7],
          }}
          transition={{
            duration: 2,
            repeat: Number.POSITIVE_INFINITY,
            ease: "easeInOut",
          }}
          className={cn(
            "rounded-full shrink-0",
            dotSizeVariants[size],
            variant === "solid" ? "bg-primary-foreground" : "bg-primary"
          )}
        />
      )}
      <span className="font-medium whitespace-nowrap">{content}</span>
    </motion.div>
  );
}

export function NativeFollowCursorArea({
  children,
  cursorContent,
  size = "md",
  variant = "default",
  offset = { x: 12, y: 12 },
  stiffness = 150,
  damping = 20,
  className,
  cursorClassName,
  showDot = false,
}: NativeFollowCursorAreaProps) {
  const [isVisible, setIsVisible] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  const cursorX = useMotionValue(0);
  const cursorY = useMotionValue(0);

  const springConfig = { stiffness, damping };
  const x = useSpring(cursorX, springConfig);
  const y = useSpring(cursorY, springConfig);

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

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

      setIsVisible(isInside);

      if (isInside) {
        cursorX.set(e.clientX + offset.x);
        cursorY.set(e.clientY + offset.y);
      }
    };

    const handleMouseLeave = () => {
      setIsVisible(false);
    };

    document.addEventListener("mousemove", handleMouseMove);
    container.addEventListener("mouseleave", handleMouseLeave);

    return () => {
      document.removeEventListener("mousemove", handleMouseMove);
      container.removeEventListener("mouseleave", handleMouseLeave);
    };
  }, [cursorX, cursorY, offset]);

  const getVariantStyles = () => {
    switch (variant) {
      case "glass":
        return "bg-background/80 backdrop-blur-md border border-border/50 shadow-lg";
      case "solid":
        return "bg-primary text-primary-foreground shadow-md";
      case "minimal":
        return "bg-background/95 border border-border shadow-sm";
      case "default":
      default:
        return "bg-popover text-popover-foreground border border-border shadow-lg";
    }
  };

  return (
    <div ref={containerRef} className={cn("relative", className)}>
      {children}
      <AnimatePresence>
        {isVisible && (
          <motion.div
            initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
            style={{
              x,
              y,
            }}
            transition={{
              opacity: { duration: 0.2 },
              scale: { duration: 0.2 },
              filter: { duration: 0.2 },
            }}
            className={cn(
              "fixed top-0 left-0 z-50 pointer-events-none select-none",
              "rounded-full flex items-center gap-2",
              sizeVariants[size],
              getVariantStyles(),
              cursorClassName
            )}
          >
            {showDot && (
              <motion.div
                animate={{
                  scale: [1, 1.2, 1],
                  opacity: [0.7, 1, 0.7],
                }}
                transition={{
                  duration: 2,
                  repeat: Number.POSITIVE_INFINITY,
                  ease: "easeInOut",
                }}
                className={cn(
                  "rounded-full shrink-0",
                  dotSizeVariants[size],
                  variant === "solid" ? "bg-primary-foreground" : "bg-primary"
                )}
              />
            )}
            <div className="font-medium">{cursorContent}</div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Installation

npx shadcn@latest add @uitripled/native-follow-cursor

Usage

import { NativeFollowCursor } from "@/components/native-follow-cursor"
<NativeFollowCursor />