1+ import { cn } from "@/lib/utils" ;
2+ import { createContext , ReactNode , RefObject , useContext , useEffect , useRef , useState } from "react" ;
3+ import { IconSearch , IconRefresh } from "@tabler/icons-react" ;
4+ import { AnimatePresence , motion , Variants } from "motion/react" ;
5+
6+ type CursorType = "default" | "link" | "button" ;
7+
8+ interface CursorTriggerProps extends React . HTMLAttributes < HTMLDivElement > {
9+ type : CursorType ;
10+ children : ReactNode ;
11+ }
12+
13+ interface CursorPosition {
14+ relativeX : number ;
15+ relativeY : number ;
16+ offsetX : number ;
17+ offsetY : number ;
18+ }
19+
20+
21+ interface CursorContextType extends CursorPosition {
22+ relativeX : number ;
23+ relativeY : number ;
24+ offsetX : number ;
25+ offsetY : number ;
26+ cursorType : CursorType
27+ ref : RefObject < HTMLDivElement > | null ;
28+ setCursorType : ( type : CursorType ) => void ;
29+ }
30+
31+ const CursorContext = createContext < CursorContextType > ( {
32+ relativeX : 0 ,
33+ relativeY : 0 ,
34+ offsetX : 0 ,
35+ offsetY : 0 ,
36+ ref : null ,
37+ cursorType : "default" ,
38+ setCursorType : ( ) => { }
39+ } ) ;
40+
41+ export default function HoverCursor ( ) {
42+ useEffect ( ( ) => {
43+ const style = document . createElement ( 'style' ) ;
44+ style . textContent = `
45+ .cursor-wrapper, .cursor-wrapper * {
46+ cursor: none !important;
47+ }
48+ ` ;
49+ document . head . appendChild ( style ) ;
50+
51+ return ( ) => {
52+ document . head . removeChild ( style ) ;
53+ } ;
54+ } , [ ] ) ;
55+
56+ return (
57+ < CursorProvider >
58+ < div className = "cursor-wrapper relative w-full h-full overflow-hidden flex items-center justify-center p-5" >
59+ < div className = "grid grid-cols-2 gap-5 items-center justify-center w-full h-full" >
60+ < CursorTrigger type = "button" className = "w-full h-full bg-fuchsia-300 rounded-2xl p-5" >
61+ < button > button - 1</ button >
62+ </ CursorTrigger >
63+ < CursorTrigger type = "link" className = "w-full h-full bg-fuchsia-400 rounded-2xl p-5" >
64+ < button > button - 2</ button >
65+ </ CursorTrigger >
66+ < CursorTrigger type = "default" className = "w-full h-full bg-fuchsia-600 rounded-2xl p-5" >
67+ < button > button - 3</ button >
68+ </ CursorTrigger >
69+ < CursorTrigger type = "default" className = "w-full h-full bg-fuchsia-700 rounded-2xl p-5" >
70+ < button > button - 4</ button >
71+ </ CursorTrigger >
72+ </ div >
73+ </ div >
74+ </ CursorProvider >
75+ )
76+ }
77+
78+
79+ export function CursorProvider ( { children } : { children : ReactNode } ) {
80+ const ref = useRef < HTMLDivElement > ( null ) ;
81+ const cursorLocation = usePointerMove ( ref ) ;
82+ const [ cursorType , setCursorType ] = useState < CursorType > ( "default" ) ;
83+
84+ return (
85+ < CursorContext . Provider value = { { ...cursorLocation , ref, cursorType, setCursorType } } >
86+ < Cursor />
87+ < div ref = { ref } className = "relative w-full h-full" >
88+ { children }
89+ </ div >
90+ </ CursorContext . Provider >
91+ )
92+ }
93+
94+ const Cursor = ( ) => {
95+ const { relativeX, relativeY, cursorType } = useContext ( CursorContext ) ;
96+ const [ index , setIndex ] = useState < number > ( 0 ) ;
97+ const [ currentNode , setCurrentNode ] = useState < ReactNode > ( null ) ;
98+
99+ useEffect ( ( ) => {
100+ setIndex ( index + 1 ) ;
101+ // keep the last node
102+ if ( cursorType !== 'default' ) {
103+ setCurrentNode ( cursorConfig [ cursorType ] ) ;
104+ } ;
105+ } , [ cursorType ] ) ;
106+
107+ const cursorConfig : Record < CursorType , ReactNode > = {
108+ 'default' : '' ,
109+ 'link' : < IconSearch className = "w-full h-full" /> ,
110+ 'button' : < IconRefresh className = "w-full h-full" />
111+ } ;
112+
113+ const variants : Variants = {
114+ default : {
115+ borderRadius : 50 ,
116+ width : 16 ,
117+ height : 16 ,
118+ background : "#18181b" ,
119+ color : "#18181b"
120+ } ,
121+ link : {
122+ borderRadius : 50 ,
123+ width : 48 ,
124+ height : 48 ,
125+ background : "#3b82f6" ,
126+ color : "#fafafa"
127+ } ,
128+ button : {
129+ borderRadius : 50 ,
130+ width : 48 ,
131+ height : 48 ,
132+ background : "#22c55e" ,
133+ color : "#fafafa"
134+ }
135+ } ;
136+
137+ const contentVariants : Variants = {
138+ initial : {
139+ x : '-100%' ,
140+ y : '100%' ,
141+ opacity : 0
142+ } ,
143+ active : {
144+ x : 0 ,
145+ y : 0 ,
146+ opacity : 1
147+ } ,
148+ exit : {
149+ x : '100%' ,
150+ y : '-100%' ,
151+ opacity : 0
152+ }
153+ } ;
154+
155+ return (
156+ < motion . div
157+ className = { cn ( "w-4 h-4 absolute shadow-[inset_0_0_0_1px_rgba(255,255,255,0.25)] z-50 top-0 left-0 overflow-hidden pointer-events-none" ) }
158+ variants = { variants }
159+ animate = { cursorType }
160+ style = { {
161+ transform : `translate(${ relativeX } px, ${ relativeY } px) translate(-50%, -50%)`
162+ } } >
163+ < div className = { cn ( "w-full h-full flex items-center justify-center" ) } >
164+ < AnimatePresence initial = { true } mode = "popLayout" >
165+ < motion . div
166+ key = { index }
167+ variants = { contentVariants }
168+ initial = "initial"
169+ animate = "active"
170+ exit = "exit"
171+ className = "w-1/2 h-1/2" >
172+ { currentNode }
173+ </ motion . div >
174+ </ AnimatePresence >
175+ </ div >
176+ </ motion . div >
177+ )
178+ }
179+
180+ const CursorTrigger = ( { type, children, ...props } : CursorTriggerProps ) => {
181+ const { setCursorType } = useContext ( CursorContext ) ;
182+ return (
183+ < div
184+ { ...props }
185+ className = { cn ( props . className ) }
186+ onMouseEnter = { ( ) => setCursorType ( type ) }
187+ onMouseLeave = { ( ) => setCursorType ( "default" ) }
188+ >
189+ { children }
190+ </ div >
191+ )
192+ }
193+
194+ const usePointerMove = ( ref : RefObject < HTMLElement > ) : CursorPosition => {
195+ const [ location , setLocation ] = useState < CursorPosition > ( {
196+ relativeX : 0 ,
197+ relativeY : 0 ,
198+ offsetX : 0 ,
199+ offsetY : 0
200+ } ) ;
201+
202+ useEffect ( ( ) => {
203+ const el = ref . current ;
204+ if ( ! el ) return ;
205+ const handlePointerMove = ( { clientX, clientY } : MouseEvent ) => {
206+ const rect = el . getBoundingClientRect ( ) ;
207+ const centerX = rect . width / 2 ;
208+ const centerY = rect . height / 2 ;
209+
210+ // 相对于元素左上角的坐标
211+ const relativeX = clientX - rect . left ;
212+ const relativeY = clientY - rect . top ;
213+
214+ // 相对于元素中心点的偏移量(可选,如果需要的话)
215+ const offsetX = ( relativeX - centerX ) / 10 * 2 ;
216+ const offsetY = ( relativeY - centerY ) / 10 * 2 ;
217+
218+ setLocation ( {
219+ relativeX,
220+ relativeY,
221+ offsetX,
222+ offsetY
223+ } ) ;
224+ } ;
225+ const handlePointerLeave = ( ) => {
226+ setLocation ( prev => {
227+ return {
228+ ...prev ,
229+ offsetX : 0 ,
230+ offsetY : 0
231+ }
232+ } )
233+ } ;
234+ const destroyListener = ( ) => {
235+ el . removeEventListener ( "pointermove" , handlePointerMove ) ;
236+ el . removeEventListener ( "pointerleave" , handlePointerLeave ) ;
237+ } ;
238+
239+ el . addEventListener ( "pointerleave" , handlePointerLeave ) ;
240+ el . addEventListener ( "pointermove" , handlePointerMove ) ;
241+
242+ return destroyListener ;
243+ } , [ ] ) ;
244+
245+ return location
246+ } ;
0 commit comments