File size: 2,497 Bytes
557ab38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/**
 * CustomCursor — a soft ink dot that follows the mouse with spring lag.
 * On hoverable elements (marked with data-cursor="focus"), the dot expands.
 * Hidden on touch devices.
 *
 * The spring gives it that "ink meeting paper" resistance instead of a
 * hard pixel-perfect follow.
 */
import { motion, useMotionValue, useSpring } from "motion/react";
import { useEffect, useState } from "react";

const SPRING = { damping: 30, stiffness: 400, mass: 0.6 };

export function CustomCursor() {
  const x = useMotionValue(-100);
  const y = useMotionValue(-100);
  const sx = useSpring(x, SPRING);
  const sy = useSpring(y, SPRING);
  const [focused, setFocused] = useState(false);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    if (typeof window === "undefined") return;
    // Bail on coarse pointers (mobile / touch) — no custom cursor.
    if (window.matchMedia("(hover: none)").matches) return;

    setVisible(true);

    const onMove = (e: MouseEvent) => {
      x.set(e.clientX);
      y.set(e.clientY);
    };
    const onOver = (e: MouseEvent) => {
      const el = e.target as HTMLElement;
      const wantsFocus = !!el.closest(
        'a, button, [role="button"], input, textarea, [data-cursor="focus"]'
      );
      setFocused(wantsFocus);
    };
    const onLeave = () => setVisible(false);
    const onEnter = () => setVisible(true);

    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseover", onOver);
    document.addEventListener("mouseleave", onLeave);
    document.addEventListener("mouseenter", onEnter);
    return () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseover", onOver);
      document.removeEventListener("mouseleave", onLeave);
      document.removeEventListener("mouseenter", onEnter);
    };
  }, [x, y]);

  if (!visible) return null;

  return (
    <>
      <motion.div
        aria-hidden
        style={{
          x: sx,
          y: sy,
          translateX: "-50%",
          translateY: "-50%",
        }}
        className="pointer-events-none fixed left-0 top-0 z-[200] mix-blend-difference"
      >
        <motion.div
          animate={{
            width: focused ? 44 : 10,
            height: focused ? 44 : 10,
            opacity: focused ? 0.65 : 0.95,
          }}
          transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
          className="rounded-full bg-white"
        />
      </motion.div>
    </>
  );
}