Skip to content

Commit 6e98389

Browse files
committed
fix(cursor): resolve any casting and react-hooks lint errors in TargetCursor
1 parent 6837e5f commit 6e98389

1 file changed

Lines changed: 359 additions & 0 deletions

File tree

src/components/TargetCursor.tsx

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
2+
import { gsap } from 'gsap';
3+
4+
// A position: fixed element is positioned relative to the viewport UNLESS an
5+
// ancestor establishes a containing block (transform, perspective, filter,
6+
// will-change of those, or contain). When that happens, the cursor's translate
7+
// no longer maps to viewport coordinates, so we measure and compensate for it.
8+
const getContainingBlock = (element: HTMLElement | null): HTMLElement | null => {
9+
let node = element?.parentElement ?? null;
10+
while (node && node !== document.documentElement) {
11+
const style = getComputedStyle(node);
12+
if (
13+
style.transform !== 'none' ||
14+
style.perspective !== 'none' ||
15+
style.filter !== 'none' ||
16+
style.willChange.includes('transform') ||
17+
style.willChange.includes('perspective') ||
18+
style.willChange.includes('filter') ||
19+
/paint|layout|strict|content/.test(style.contain)
20+
) {
21+
return node;
22+
}
23+
node = node.parentElement;
24+
}
25+
return null;
26+
};
27+
28+
const getContainingBlockOffset = (block: HTMLElement | null): { x: number; y: number } => {
29+
if (!block) return { x: 0, y: 0 };
30+
const rect = block.getBoundingClientRect();
31+
return { x: rect.left + block.clientLeft, y: rect.top + block.clientTop };
32+
};
33+
34+
export interface TargetCursorProps {
35+
targetSelector?: string;
36+
spinDuration?: number;
37+
hideDefaultCursor?: boolean;
38+
hoverDuration?: number;
39+
parallaxOn?: boolean;
40+
}
41+
42+
const TargetCursor: React.FC<TargetCursorProps> = ({
43+
targetSelector = '.cursor-target',
44+
spinDuration = 2,
45+
hideDefaultCursor = true,
46+
hoverDuration = 0.2,
47+
parallaxOn = true
48+
}) => {
49+
const cursorRef = useRef<HTMLDivElement>(null);
50+
const cornersRef = useRef<NodeListOf<HTMLDivElement> | null>(null);
51+
const spinTl = useRef<gsap.core.Timeline | null>(null);
52+
const dotRef = useRef<HTMLDivElement>(null);
53+
const containingBlockRef = useRef<HTMLElement | null>(null);
54+
55+
const isActiveRef = useRef(false);
56+
const targetCornerPositionsRef = useRef<{ x: number; y: number }[] | null>(null);
57+
const tickerFnRef = useRef<(() => void) | null>(null);
58+
const activeStrengthRef = useRef({ current: 0 });
59+
60+
const isMobile = useMemo(() => {
61+
if (typeof window === 'undefined') return false;
62+
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
63+
const isSmallScreen = window.innerWidth <= 768;
64+
const userAgent = navigator.userAgent || navigator.vendor || ("opera" in window ? (window as unknown as { opera: string }).opera : "");
65+
const mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
66+
const isMobileUserAgent = mobileRegex.test(userAgent.toLowerCase());
67+
return (hasTouchScreen && isSmallScreen) || isMobileUserAgent;
68+
}, []);
69+
70+
const constants = useMemo(() => ({ borderWidth: 3, cornerSize: 12 }), []);
71+
72+
const moveCursor = useCallback((x: number, y: number) => {
73+
if (!cursorRef.current) return;
74+
const { x: offsetX, y: offsetY } = getContainingBlockOffset(containingBlockRef.current);
75+
gsap.to(cursorRef.current, { x: x - offsetX, y: y - offsetY, duration: 0.1, ease: 'power3.out' });
76+
}, []);
77+
78+
useEffect(() => {
79+
if (isMobile || !cursorRef.current) return;
80+
81+
const activeStrength = activeStrengthRef.current;
82+
const originalCursor = document.body.style.cursor;
83+
if (hideDefaultCursor) {
84+
document.body.style.cursor = 'none';
85+
}
86+
87+
const cursor = cursorRef.current;
88+
cornersRef.current = cursor.querySelectorAll<HTMLDivElement>('.target-cursor-corner');
89+
90+
containingBlockRef.current = getContainingBlock(cursor);
91+
const getOffset = () => getContainingBlockOffset(containingBlockRef.current);
92+
93+
let activeTarget: Element | null = null;
94+
let currentLeaveHandler: (() => void) | null = null;
95+
let resumeTimeout: ReturnType<typeof setTimeout> | null = null;
96+
97+
const cleanupTarget = (target: Element) => {
98+
if (currentLeaveHandler) {
99+
target.removeEventListener('mouseleave', currentLeaveHandler);
100+
}
101+
currentLeaveHandler = null;
102+
};
103+
104+
const initialOffset = getOffset();
105+
gsap.set(cursor, {
106+
xPercent: -50,
107+
yPercent: -50,
108+
x: window.innerWidth / 2 - initialOffset.x,
109+
y: window.innerHeight / 2 - initialOffset.y
110+
});
111+
112+
const createSpinTimeline = () => {
113+
if (spinTl.current) {
114+
spinTl.current.kill();
115+
}
116+
spinTl.current = gsap
117+
.timeline({ repeat: -1 })
118+
.to(cursor, { rotation: '+=360', duration: spinDuration, ease: 'none' });
119+
};
120+
121+
createSpinTimeline();
122+
123+
const tickerFn = () => {
124+
if (!targetCornerPositionsRef.current || !cursorRef.current || !cornersRef.current) {
125+
return;
126+
}
127+
const strength = activeStrengthRef.current.current;
128+
if (strength === 0) return;
129+
const cursorX = gsap.getProperty(cursorRef.current, 'x') as number;
130+
const cursorY = gsap.getProperty(cursorRef.current, 'y') as number;
131+
const corners = Array.from(cornersRef.current);
132+
corners.forEach((corner, i) => {
133+
const currentX = gsap.getProperty(corner, 'x') as number;
134+
const currentY = gsap.getProperty(corner, 'y') as number;
135+
const targetX = targetCornerPositionsRef.current![i].x - cursorX;
136+
const targetY = targetCornerPositionsRef.current![i].y - cursorY;
137+
const finalX = currentX + (targetX - currentX) * strength;
138+
const finalY = currentY + (targetY - currentY) * strength;
139+
const duration = strength >= 0.99 ? (parallaxOn ? 0.2 : 0) : 0.05;
140+
gsap.to(corner, {
141+
x: finalX,
142+
y: finalY,
143+
duration: duration,
144+
ease: duration === 0 ? 'none' : 'power1.out',
145+
overwrite: 'auto'
146+
});
147+
});
148+
};
149+
150+
tickerFnRef.current = tickerFn;
151+
152+
const moveHandler = (e: MouseEvent) => moveCursor(e.clientX, e.clientY);
153+
window.addEventListener('mousemove', moveHandler);
154+
155+
const scrollHandler = () => {
156+
if (!activeTarget || !cursorRef.current) return;
157+
const { x: offsetX, y: offsetY } = getOffset();
158+
const mouseX = (gsap.getProperty(cursorRef.current, 'x') as number) + offsetX;
159+
const mouseY = (gsap.getProperty(cursorRef.current, 'y') as number) + offsetY;
160+
const elementUnderMouse = document.elementFromPoint(mouseX, mouseY);
161+
const isStillOverTarget =
162+
elementUnderMouse &&
163+
(elementUnderMouse === activeTarget || elementUnderMouse.closest(targetSelector) === activeTarget);
164+
if (!isStillOverTarget) {
165+
currentLeaveHandler?.();
166+
}
167+
};
168+
window.addEventListener('scroll', scrollHandler, { passive: true });
169+
170+
const mouseDownHandler = () => {
171+
if (!dotRef.current) return;
172+
gsap.to(dotRef.current, { scale: 0.7, duration: 0.3 });
173+
gsap.to(cursorRef.current, { scale: 0.9, duration: 0.2 });
174+
};
175+
176+
const mouseUpHandler = () => {
177+
if (!dotRef.current) return;
178+
gsap.to(dotRef.current, { scale: 1, duration: 0.3 });
179+
gsap.to(cursorRef.current, { scale: 1, duration: 0.2 });
180+
};
181+
182+
window.addEventListener('mousedown', mouseDownHandler);
183+
window.addEventListener('mouseup', mouseUpHandler);
184+
185+
const enterHandler = (e: MouseEvent) => {
186+
const directTarget = e.target as Element;
187+
const allTargets: Element[] = [];
188+
let current: Element | null = directTarget;
189+
while (current && current !== document.body) {
190+
if (current.matches(targetSelector)) {
191+
allTargets.push(current);
192+
}
193+
current = current.parentElement;
194+
}
195+
const target = allTargets[0] || null;
196+
if (!target || !cursorRef.current || !cornersRef.current) return;
197+
if (activeTarget === target) return;
198+
if (activeTarget) {
199+
cleanupTarget(activeTarget);
200+
}
201+
if (resumeTimeout) {
202+
clearTimeout(resumeTimeout);
203+
resumeTimeout = null;
204+
}
205+
206+
activeTarget = target;
207+
const corners = Array.from(cornersRef.current);
208+
corners.forEach(corner => gsap.killTweensOf(corner));
209+
gsap.killTweensOf(cursorRef.current, 'rotation');
210+
spinTl.current?.pause();
211+
gsap.set(cursorRef.current, { rotation: 0 });
212+
213+
const rect = target.getBoundingClientRect();
214+
const { borderWidth, cornerSize } = constants;
215+
const { x: offsetX, y: offsetY } = getOffset();
216+
const cursorX = gsap.getProperty(cursorRef.current, 'x') as number;
217+
const cursorY = gsap.getProperty(cursorRef.current, 'y') as number;
218+
219+
targetCornerPositionsRef.current = [
220+
{ x: rect.left - borderWidth - offsetX, y: rect.top - borderWidth - offsetY },
221+
{ x: rect.right + borderWidth - cornerSize - offsetX, y: rect.top - borderWidth - offsetY },
222+
{ x: rect.right + borderWidth - cornerSize - offsetX, y: rect.bottom + borderWidth - cornerSize - offsetY },
223+
{ x: rect.left - borderWidth - offsetX, y: rect.bottom + borderWidth - cornerSize - offsetY }
224+
];
225+
226+
isActiveRef.current = true;
227+
gsap.ticker.add(tickerFnRef.current!);
228+
229+
gsap.to(activeStrengthRef.current, { current: 1, duration: hoverDuration, ease: 'power2.out' });
230+
231+
corners.forEach((corner, i) => {
232+
gsap.to(corner, {
233+
x: targetCornerPositionsRef.current![i].x - cursorX,
234+
y: targetCornerPositionsRef.current![i].y - cursorY,
235+
duration: 0.2,
236+
ease: 'power2.out'
237+
});
238+
});
239+
240+
const leaveHandler = () => {
241+
gsap.ticker.remove(tickerFnRef.current!);
242+
isActiveRef.current = false;
243+
targetCornerPositionsRef.current = null;
244+
gsap.set(activeStrengthRef.current, { current: 0, overwrite: true });
245+
activeTarget = null;
246+
if (cornersRef.current) {
247+
const corners = Array.from(cornersRef.current);
248+
gsap.killTweensOf(corners);
249+
const { cornerSize } = constants;
250+
const positions = [
251+
{ x: -cornerSize * 1.5, y: -cornerSize * 1.5 },
252+
{ x: cornerSize * 0.5, y: -cornerSize * 1.5 },
253+
{ x: cornerSize * 0.5, y: cornerSize * 0.5 },
254+
{ x: -cornerSize * 1.5, y: cornerSize * 0.5 }
255+
];
256+
const tl = gsap.timeline();
257+
corners.forEach((corner, index) => {
258+
tl.to(corner, { x: positions[index].x, y: positions[index].y, duration: 0.3, ease: 'power3.out' }, 0);
259+
});
260+
}
261+
resumeTimeout = setTimeout(() => {
262+
if (!activeTarget && cursorRef.current && spinTl.current) {
263+
const currentRotation = gsap.getProperty(cursorRef.current, 'rotation') as number;
264+
const normalizedRotation = currentRotation % 360;
265+
spinTl.current.kill();
266+
spinTl.current = gsap
267+
.timeline({ repeat: -1 })
268+
.to(cursorRef.current, { rotation: '+=360', duration: spinDuration, ease: 'none' });
269+
gsap.to(cursorRef.current, {
270+
rotation: normalizedRotation + 360,
271+
duration: spinDuration * (1 - normalizedRotation / 360),
272+
ease: 'none',
273+
onComplete: () => {
274+
spinTl.current?.restart();
275+
}
276+
});
277+
}
278+
resumeTimeout = null;
279+
}, 50);
280+
cleanupTarget(target);
281+
};
282+
currentLeaveHandler = leaveHandler;
283+
target.addEventListener('mouseleave', leaveHandler);
284+
};
285+
286+
window.addEventListener('mouseover', enterHandler as EventListener);
287+
288+
const resizeHandler = () => {
289+
containingBlockRef.current = getContainingBlock(cursor);
290+
};
291+
window.addEventListener('resize', resizeHandler);
292+
293+
return () => {
294+
if (tickerFnRef.current) {
295+
gsap.ticker.remove(tickerFnRef.current);
296+
}
297+
window.removeEventListener('mousemove', moveHandler);
298+
window.removeEventListener('mouseover', enterHandler as EventListener);
299+
window.removeEventListener('scroll', scrollHandler);
300+
window.removeEventListener('resize', resizeHandler);
301+
window.removeEventListener('mousedown', mouseDownHandler);
302+
window.removeEventListener('mouseup', mouseUpHandler);
303+
if (activeTarget) {
304+
cleanupTarget(activeTarget);
305+
}
306+
spinTl.current?.kill();
307+
document.body.style.cursor = originalCursor;
308+
isActiveRef.current = false;
309+
targetCornerPositionsRef.current = null;
310+
activeStrength.current = 0;
311+
};
312+
}, [targetSelector, spinDuration, moveCursor, constants, hideDefaultCursor, isMobile, hoverDuration, parallaxOn]);
313+
314+
useEffect(() => {
315+
if (isMobile || !cursorRef.current || !spinTl.current) return;
316+
if (spinTl.current.isActive()) {
317+
spinTl.current.kill();
318+
spinTl.current = gsap
319+
.timeline({ repeat: -1 })
320+
.to(cursorRef.current, { rotation: '+=360', duration: spinDuration, ease: 'none' });
321+
}
322+
}, [spinDuration, isMobile]);
323+
324+
if (isMobile) {
325+
return null;
326+
}
327+
328+
return (
329+
<div
330+
ref={cursorRef}
331+
className="fixed top-0 left-0 w-0 h-0 pointer-events-none z-[9999]"
332+
style={{ willChange: 'transform' }}
333+
>
334+
<div
335+
ref={dotRef}
336+
className="absolute top-1/2 left-1/2 w-1 h-1 bg-white rounded-full -translate-x-1/2 -translate-y-1/2"
337+
style={{ willChange: 'transform' }}
338+
/>
339+
<div
340+
className="target-cursor-corner absolute top-1/2 left-1/2 w-3 h-3 border-[3px] border-white -translate-x-[150%] -translate-y-[150%] border-r-0 border-b-0"
341+
style={{ willChange: 'transform' }}
342+
/>
343+
<div
344+
className="target-cursor-corner absolute top-1/2 left-1/2 w-3 h-3 border-[3px] border-white translate-x-1/2 -translate-y-[150%] border-l-0 border-b-0"
345+
style={{ willChange: 'transform' }}
346+
/>
347+
<div
348+
className="target-cursor-corner absolute top-1/2 left-1/2 w-3 h-3 border-[3px] border-white translate-x-1/2 translate-y-1/2 border-l-0 border-t-0"
349+
style={{ willChange: 'transform' }}
350+
/>
351+
<div
352+
className="target-cursor-corner absolute top-1/2 left-1/2 w-3 h-3 border-[3px] border-white -translate-x-[150%] translate-y-1/2 border-r-0 border-t-0"
353+
style={{ willChange: 'transform' }}
354+
/>
355+
</div>
356+
);
357+
};
358+
359+
export default TargetCursor;

0 commit comments

Comments
 (0)