A custom cursor component with optional spring animations.
1'use client';
2import React, { useEffect, useState, useRef } from 'react';
3import {
4 motion,
5 SpringOptions,
6 useMotionValue,
7 useSpring,
8 AnimatePresence,
9 Transition,
10 Variant,
11} from 'motion/react';
12import { cn } from '@/lib/utils';
13
14export type CursorProps = {
15 children: React.ReactNode;
16 className?: string;
17 springConfig?: SpringOptions;
18 attachToParent?: boolean;
19 transition?: Transition;
20 variants?: {
21 initial: Variant;
22 animate: Variant;
23 exit: Variant;
24 };
25 onPositionChange?: (x: number, y: number) => void;
26};
27
28export function Cursor({
29 children,
30 className,
31 springConfig,
32 attachToParent,
33 variants,
34 transition,
35 onPositionChange,
36}: CursorProps) {
37 const cursorX = useMotionValue(0);
38 const cursorY = useMotionValue(0);
39 const cursorRef = useRef<HTMLDivElement>(null);
40 const [isVisible, setIsVisible] = useState(!attachToParent);
41
42 useEffect(() => {
43 if (typeof window !== 'undefined') {
44 cursorX.set(window.innerWidth / 2);
45 cursorY.set(window.innerHeight / 2);
46 }
47 }, []);
48
49 useEffect(() => {
50 if (!attachToParent) {
51 document.body.style.cursor = 'none';
52 } else {
53 document.body.style.cursor = 'auto';
54 }
55
56 const updatePosition = (e: MouseEvent) => {
57 cursorX.set(e.clientX);
58 cursorY.set(e.clientY);
59 onPositionChange?.(e.clientX, e.clientY);
60 };
61
62 document.addEventListener('mousemove', updatePosition);
63
64 return () => {
65 document.removeEventListener('mousemove', updatePosition);
66 };
67 }, [cursorX, cursorY, onPositionChange]);
68
69 const cursorXSpring = useSpring(cursorX, springConfig || { duration: 0 });
70 const cursorYSpring = useSpring(cursorY, springConfig || { duration: 0 });
71
72 useEffect(() => {
73 const handleVisibilityChange = (visible: boolean) => {
74 setIsVisible(visible);
75 };
76
77 if (attachToParent && cursorRef.current) {
78 const parent = cursorRef.current.parentElement;
79 if (parent) {
80 parent.addEventListener('mouseenter', () => {
81 parent.style.cursor = 'none';
82 handleVisibilityChange(true);
83 });
84 parent.addEventListener('mouseleave', () => {
85 parent.style.cursor = 'auto';
86 handleVisibilityChange(false);
87 });
88 }
89 }
90
91 return () => {
92 if (attachToParent && cursorRef.current) {
93 const parent = cursorRef.current.parentElement;
94 if (parent) {
95 parent.removeEventListener('mouseenter', () => {
96 parent.style.cursor = 'none';
97 handleVisibilityChange(true);
98 });
99 parent.removeEventListener('mouseleave', () => {
100 parent.style.cursor = 'auto';
101 handleVisibilityChange(false);
102 });
103 }
104 }
105 };
106 }, [attachToParent]);
107
108 return (
109 <motion.div
110 ref={cursorRef}
111 className={cn('pointer-events-none fixed left-0 top-0 z-50', className)}
112 style={{
113 x: cursorXSpring,
114 y: cursorYSpring,
115 translateX: '-50%',
116 translateY: '-50%',
117 }}
118 >
119 <AnimatePresence>
120 {isVisible && (
121 <motion.div
122 initial='initial'
123 animate='animate'
124 exit='exit'
125 variants={variants}
126 transition={transition}
127 >
128 {children}
129 </motion.div>
130 )}
131 </AnimatePresence>
132 </motion.div>
133 );
134}
135