Skip to content

Commit b2f2c5d

Browse files
committed
feat(lab):hover cursor
1 parent 0c50f6b commit b2f2c5d

File tree

2 files changed

+257
-0
lines changed

2 files changed

+257
-0
lines changed

src/data/labs/data.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@
5252
"CSS"
5353
]
5454
},
55+
"hover-cursor": {
56+
"name": "hover-cursor",
57+
"inDevelopment": true,
58+
"en_title": "Hover Cursor",
59+
"origin_url": "",
60+
"title": "悬停光标",
61+
"tags": [
62+
"Motion",
63+
"Tailwind"
64+
]
65+
},
5566
"dnd-stack": {
5667
"name": "dnd-stack",
5768
"inDevelopment": true,
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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

Comments
 (0)