Todos os componentes

Cursor Follow

Cursor Follow é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT other

O que é

Cursor Follow é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i motion

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Cursor Follow

index.tsx
"use client";

import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
} from "motion/react";
import type React from "react";
import { useEffect, useRef, useState } from "react";

import { useCursorPosition } from "./use-cursor-position";

export interface CursorFollowProps {
  children: React.ReactNode;
  className?: string;
}

const CIRCLE_SIZE = 16;
const MIN_BUBBLE_WIDTH = 40;
const BUBBLE_HEIGHT = 40;
const TEXT_PADDING = 32;

const CursorFollow: React.FC<CursorFollowProps> = ({
  children,
  className = "",
}) => {
  const { x: mouseX, y: mouseY } = useCursorPosition();
  const [cursorText, setCursorText] = useState<string | null>(null);
  const [pendingText, setPendingText] = useState<string | null>(null);
  const [textWidth, setTextWidth] = useState<number>(0);
  const measureRef = useRef<HTMLSpanElement>(null);
  const shouldReduceMotion = useReducedMotion();

  // Motion values for smooth follow
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const springX = useSpring(x, { damping: 40, stiffness: 350 });
  const springY = useSpring(y, { damping: 40, stiffness: 350 });

  // Calculate bubble width and height
  const bubbleWidth = cursorText
    ? Math.max(textWidth + TEXT_PADDING, MIN_BUBBLE_WIDTH)
    : CIRCLE_SIZE;
  const bubbleHeight = cursorText ? BUBBLE_HEIGHT : CIRCLE_SIZE;

  // Update target position on mouse move
  useEffect(() => {
    x.set(mouseX - bubbleWidth / 2);
    y.set(mouseY - bubbleHeight / 2);
  }, [mouseX, mouseY, bubbleWidth, bubbleHeight, x, y]);

  // Pre-measure text width before showing bubble
  useEffect(() => {
    if (pendingText && measureRef.current) {
      const width = measureRef.current.offsetWidth;
      setTextWidth(width);
      setCursorText(pendingText);
      setPendingText(null);
    }
    if (!(pendingText || cursorText)) {
      setTextWidth(0);
    }
  }, [pendingText, cursorText]);

  // Handlers for child hover
  const handleMouseOver = (e: React.MouseEvent) => {
    const target = e.target as HTMLElement;
    const text = target.getAttribute("data-cursor-text");
    if (text) {
      setPendingText(text);
    }
  };
  const handleMouseOut = () => {
    setCursorText(null);
    setPendingText(null);
  };
  const handleFocus = (e: React.FocusEvent) => {
    const target = e.target as HTMLElement;
    const text = target.getAttribute("data-cursor-text");
    if (text) {
      setPendingText(text);
    }
  };
  const handleBlur = () => {
    setCursorText(null);
    setPendingText(null);
  };

  return (
    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: Interactive cursor tracking widget requires mouse events
    <div
      className={`relative h-full w-full ${className}`}
      onBlur={handleBlur}
      onFocus={handleFocus}
      onMouseOut={handleMouseOut}
      onMouseOver={handleMouseOver}
      role="application"
      style={{ cursor: "none", minHeight: 300 }}
      // biome-ignore lint/a11y/noNoninteractiveTabindex: Interactive cursor tracking widget requires focus
      tabIndex={0}
    >
      {children}
      <motion.div
        animate={
          shouldReduceMotion
            ? { opacity: 1, scale: 1 }
            : {
                opacity: 1,
                scale: 1,
                transition: {
                  duration: 0.25,
                  ease: [0.645, 0.045, 0.355, 1],
                },
              }
        }
        className="pointer-events-none fixed z-50"
        exit={shouldReduceMotion ? {} : { opacity: 0, scale: 0.7 }}
        initial={
          shouldReduceMotion
            ? { opacity: 1, scale: 1 }
            : { opacity: 0, scale: 0.7 }
        }
        style={{ left: 0, top: 0, x: springX, y: springY }}
      >
        <motion.div
          animate={
            cursorText
              ? {
                  background: "var(--color-brand, #6366f1)",
                  borderRadius: 20,
                  color: "#fff",
                  height: 40,
                  minHeight: 32,
                  minWidth: 40,
                  paddingLeft: 16,
                  paddingRight: 16,
                  scale: 1.1,
                  width: bubbleWidth,
                }
              : {
                  background: "var(--color-brand, #6366f1)",
                  borderRadius: 999,
                  color: "#fff",
                  height: CIRCLE_SIZE,
                  minHeight: CIRCLE_SIZE,
                  minWidth: CIRCLE_SIZE,
                  paddingLeft: 0,
                  paddingRight: 0,
                  scale: 1,
                  width: CIRCLE_SIZE,
                }
          }
          className="flex items-center justify-center font-medium text-xs shadow-lg"
          layout
          style={{
            alignItems: "center",
            boxShadow: "0 2px 8px 0 rgba(0,0,0,0.10)",
            display: "flex",
            justifyContent: "center",
            position: "relative",
            zIndex: 1,
          }}
          transition={
            shouldReduceMotion
              ? { duration: 0 }
              : { duration: 0.25, ease: [0.645, 0.045, 0.355, 1] }
          }
        >
          {cursorText ? (
            <motion.span
              animate={{ filter: "blur(0px)", opacity: 1 }}
              exit={{ filter: "blur(8px)", opacity: 0 }}
              initial={{ filter: "blur(8px)", opacity: 0 }}
              style={{
                color: "#fff",
                textAlign: "center",
                whiteSpace: "nowrap",
                width: "100%",
              }}
              transition={
                shouldReduceMotion
                  ? { duration: 0 }
                  : {
                      delay: 0.05,
                      duration: 0.2,
                      ease: [0.645, 0.045, 0.355, 1],
                    }
              }
            >
              {cursorText}
            </motion.span>
          ) : null}
        </motion.div>
        {/* Hidden span for pre-measuring text width */}
        {pendingText || cursorText ? (
          <span
            ref={measureRef}
            style={{
              fontFamily: "inherit",
              fontSize: "0.75rem",
              fontWeight: 500,
              paddingLeft: 16,
              paddingRight: 16,
              pointerEvents: "none",
              position: "absolute",
              visibility: "hidden",
              whiteSpace: "nowrap",
            }}
          >
            {pendingText || cursorText}
          </span>
        ) : null}
      </motion.div>
    </div>
  );
};

export default CursorFollow;
use-cursor-position.tsx
import { useEffect, useState } from "react";

export function useCursorPosition() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (event: MouseEvent) => {
      setPosition({ x: event.clientX, y: event.clientY });
    };
    window.addEventListener("mousemove", handleMouseMove);
    return () => window.removeEventListener("mousemove", handleMouseMove);
  }, []);

  return position;
}

Componentes parecidos