TargetCursor
A cursor follow animation with 4 corners that lock onto targets.
Live preview
Loading demo…
Move your cursor over the buttons.
Installation
npx shadcn@latest add https://reactbits.dev/r/TargetCursor-TS-TW.json
Dependencies: react-domgsap
Source
'use client';
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { gsap } from 'gsap';
// A position: fixed element is positioned relative to the viewport UNLESS an
// ancestor establishes a containing block (transform, perspective, filter,
// will-change of those, or contain). When that happens, the cursor's translate
// no longer maps to viewport coordinates, so we measure and compensate for it.
const getContainingBlock = (element: HTMLElement | null): HTMLElement | null => {
let node = element?.parentElement ?? null;
while (node && node !== document.documentElement) {
const style = getComputedStyle(node);
if (
style.transform !== 'none' ||
style.perspective !== 'none' ||
style.filter !== 'none' ||
style.willChange.includes('transform') ||
style.willChange.includes('perspective') ||
style.willChange.includes('filter') ||
/paint|layout|strict|content/.test(style.contain)
) {
return node;
}
node = node.parentElement;
}
return null;
};
const getContainingBlockOffset = (block: HTMLElement | null): { x: number; y: number } => {
if (!block) return { x: 0, y: 0 };
const rect = block.getBoundingClientRect();
return { x: rect.left + block.clientLeft, y: rect.top + block.clientTop };
};
export interface TargetCursorProps {
targetSelector?: string;
spinDuration?: number;
hideDefaultCursor?: boolean;
hoverDuration?: number;
parallaxOn?: boolean;
cursorColor?: string;
cursorColorOnTarget?: string;
}
const TargetCursor: React.FC<TargetCursorProps> = ({
targetSelector = '.cursor-target',
spinDuration = 2,
hideDefaultCursor = true,
hoverDuration = 0.2,
parallaxOn = true,
cursorColor = '#ffffff',
cursorColorOnTarget
}) => {
const cursorRef = useRef<HTMLDivElement>(null);
const cornersRef = useRef<NodeListOf<HTMLDivElement> | null>(null);
const spinTl = useRef<gsap.core.Timeline | null>(null);
const dotRef = useRef<HTMLDivElement>(null);
const containingBlockRef = useRef<HTMLElement | null>(null);
const isActiveRef = useRef(false);
const targetCornerPositionsRef = useRef<{ x: number; y: number }[] | null>(null);
const tickerFnRef = useRef<(() => void) | null>(null);
const activeStrengthRef = useRef({ current: 0 });
const isMobile = useMemo(() => {
if (typeof window === 'undefined') return false;
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const isSmallScreen = window.innerWidth <= 768;
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
const mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
const isMobileUserAgent = mobileRegex.test(userAgent.toLowerCase());
return (hasTouchScreen && isSmallScreen) || isMobileUserAgent;
}, []);
const constants = useMemo(() => ({ borderWidth: 3, cornerSize: 12 }), []);
const moveCursor = useCallback((x: number, y: number) => {
if (!cursorRef.current) return;
const { x: offsetX, y: offsetY } = getContainingBlockOffset(containingBlockRef.current);
gsap.to(cursorRef.current, { x: x - offsetX, y: y - offsetY, duration: 0.1, ease: 'power3.out' });
}, []);
useEffect(() => {
if (isMobile || !cursorRef.current) return;
const originalCursor = document.body.style.cursor;
if (hideDefaultCursor) {
document.body.style.cursor = 'none';
}
const cursor = cursorRef.current;
cornersRef.current = cursor.querySelectorAll<HTMLDivElement>('.target-cursor-corner');
containingBlockRef.current = getContainingBlock(cursor);
const getOffset = () => getContainingBlockOffset(containingBlockRef.current);
let activeTarget: Element | null = null;
let currentLeaveHandler: (() => void) | null = null;
let resumeTimeout: ReturnType<typeof setTimeout> | null = null;
const cleanupTarget = (target: Element) => {
if (currentLeaveHandler) {
target.removeEventListener('mouseleave', currentLeaveHandler);
}
currentLeaveHandler = null;
};
const initialOffset = getOffset();
gsap.set(cursor, {
xPercent: -50,
yPercent: -50,
x: window.innerWidth / 2 - initialOffset.x,
y: window.innerHeight / 2 - initialOffset.y
});
const createSpinTimeline = () => {
if (spinTl.current) {
spinTl.current.kill();
}
spinTl.current = gsap
.timeline({ repeat: -1 })
.to(cursor, { rotation: '+=360', duration: spinDuration, ease: 'none' });
};
createSpinTimeline();
const tickerFn = () => {
if (!targetCornerPositionsRef.current || !cursorRef.current || !cornersRef.current) {
return;
}
const strength = activeStrengthRef.current.current;
if (strength === 0) return;
const cursorX = gsap.getProperty(cursorRef.current, 'x') as number;
const cursorY = gsap.getProperty(cursorRef.current, 'y') as number;
const corners = Array.from(cornersRef.current);
corners.forEach((corner, i) => {
const currentX = gsap.getProperty(corner, 'x') as number;
const currentY = gsap.getProperty(corner, 'y') as number;
const targetX = targetCornerPositionsRef.current![i].x - cursorX;
const targetY = targetCornerPositionsRef.current![i].y - cursorY;
const finalX = currentX + (targetX - currentX) * strength;
const finalY = currentY + (targetY - currentY) * strength;
const duration = strength >= 0.99 ? (parallaxOn ? 0.2 : 0) : 0.05;
gsap.to(corner, {
x: finalX,
y: finalY,
duration: duration,
ease: duration === 0 ? 'none' : 'power1.out',
overwrite: 'auto'
});
});
};
tickerFnRef.current = tickerFn;
const moveHandler = (e: MouseEvent) => moveCursor(e.clientX, e.clientY);
window.addEventListener('mousemove', moveHandler);
const scrollHandler = () => {
if (!activeTarget || !cursorRef.current) return;
const { x: offsetX, y: offsetY } = getOffset();
const mouseX = (gsap.getProperty(cursorRef.current, 'x') as number) + offsetX;
const mouseY = (gsap.getProperty(cursorRef.current, 'y') as number) + offsetY;
const elementUnderMouse = document.elementFromPoint(mouseX, mouseY);
const isStillOverTarget =
elementUnderMouse &&
(elementUnderMouse === activeTarget || elementUnderMouse.closest(targetSelector) === activeTarget);
if (!isStillOverTarget) {
currentLeaveHandler?.();
}
};
window.addEventListener('scroll', scrollHandler, { passive: true });
const mouseDownHandler = () => {
if (!dotRef.current) return;
gsap.to(dotRef.current, { scale: 0.7, duration: 0.3 });
gsap.to(cursorRef.current, { scale: 0.9, duration: 0.2 });
};
const mouseUpHandler = () => {
if (!dotRef.current) return;
gsap.to(dotRef.current, { scale: 1, duration: 0.3 });
gsap.to(cursorRef.current, { scale: 1, duration: 0.2 });
};
window.addEventListener('mousedown', mouseDownHandler);
window.addEventListener('mouseup', mouseUpHandler);
const enterHandler = (e: MouseEvent) => {
const directTarget = e.target as Element;
const allTargets: Element[] = [];
let current: Element | null = directTarget;
while (current && current !== document.body) {
if (current.matches(targetSelector)) {
allTargets.push(current);
}
current = current.parentElement;
}
const target = allTargets[0] || null;
if (!target || !cursorRef.current || !cornersRef.current) return;
if (activeTarget === target) return;
if (activeTarget) {
cleanupTarget(activeTarget);
}
if (resumeTimeout) {
clearTimeout(resumeTimeout);
resumeTimeout = null;
}
activeTarget = target;
const corners = Array.from(cornersRef.current);
corners.forEach(corner => gsap.killTweensOf(corner, 'x,y'));
gsap.killTweensOf(cursorRef.current, 'rotation');
spinTl.current?.pause();
gsap.set(cursorRef.current, { rotation: 0 });
if (cursorColorOnTarget) {
gsap.to(corners, {
borderColor: cursorColorOnTarget,
duration: 0.15,
ease: 'power2.out'
});
if (dotRef.current) {
gsap.to(dotRef.current, {
backgroundColor: cursorColorOnTarget,
duration: 0.15,
ease: 'power2.out'
});
}
}
const rect = target.getBoundingClientRect();
const { borderWidth, cornerSize } = constants;
const { x: offsetX, y: offsetY } = getOffset();
const cursorX = gsap.getProperty(cursorRef.current, 'x') as number;
const cursorY = gsap.getProperty(cursorRef.current, 'y') as number;
targetCornerPositionsRef.current = [
{ x: rect.left - borderWidth - offsetX, y: rect.top - borderWidth - offsetY },
{ x: rect.right + borderWidth - cornerSize - offsetX, y: rect.top - borderWidth - offsetY },
{ x: rect.right + borderWidth - cornerSize - offsetX, y: rect.bottom + borderWidth - cornerSize - offsetY },
{ x: rect.left - borderWidth - offsetX, y: rect.bottom + borderWidth - cornerSize - offsetY }
];
isActiveRef.current = true;
gsap.ticker.add(tickerFnRef.current!);
gsap.to(activeStrengthRef.current, { current: 1, duration: hoverDuration, ease: 'power2.out' });
corners.forEach((corner, i) => {
gsap.to(corner, {
x: targetCornerPositionsRef.current![i].x - cursorX,
y: targetCornerPositionsRef.current![i].y - cursorY,
duration: 0.2,
ease: 'power2.out'
});
});
const leaveHandler = () => {
gsap.ticker.remove(tickerFnRef.current!);
isActiveRef.current = false;
targetCornerPositionsRef.current = null;
gsap.set(activeStrengthRef.current, { current: 0, overwrite: true });
activeTarget = null;
if (cursorColorOnTarget && cornersRef.current) {
gsap.to(Array.from(cornersRef.current), {
borderColor: cursorColor,
duration: 0.15,
ease: 'power2.out'
});
if (dotRef.current) {
gsap.to(dotRef.current, {
backgroundColor: cursorColor,
duration: 0.15,
ease: 'power2.out'
});
}
}
if (cornersRef.current) {
const corners = Array.from(cornersRef.current);
gsap.killTweensOf(corners, 'x,y');
const { cornerSize } = constants;
const positions = [
{ x: -cornerSize * 1.5, y: -cornerSize * 1.5 },
{ x: cornerSize * 0.5, y: -cornerSize * 1.5 },
{ x: cornerSize * 0.5, y: cornerSize * 0.5 },
{ x: -cornerSize * 1.5, y: cornerSize * 0.5 }
];
const tl = gsap.timeline();
corners.forEach((corner, index) => {
tl.to(corner, { x: positions[index].x, y: positions[index].y, duration: 0.3, ease: 'power3.out' }, 0);
});
}
resumeTimeout = setTimeout(() => {
if (!activeTarget && cursorRef.current && spinTl.current) {
const currentRotation = gsap.getProperty(cursorRef.current, 'rotation') as number;
const normalizedRotation = currentRotation % 360;
spinTl.current.kill();
spinTl.current = gsap
.timeline({ repeat: -1 })
.to(cursorRef.current, { rotation: '+=360', duration: spinDuration, ease: 'none' });
gsap.to(cursorRef.current, {
rotation: normalizedRotation + 360,
duration: spinDuration * (1 - normalizedRotation / 360),
ease: 'none',
onComplete: () => {
spinTl.current?.restart();
}
});
}
resumeTimeout = null;
}, 50);
cleanupTarget(target);
};
currentLeaveHandler = leaveHandler;
target.addEventListener('mouseleave', leaveHandler);
};
window.addEventListener('mouseover', enterHandler as EventListener);
const resizeHandler = () => {
containingBlockRef.current = getContainingBlock(cursor);
};
window.addEventListener('resize', resizeHandler);
return () => {
if (tickerFnRef.current) {
gsap.ticker.remove(tickerFnRef.current);
}
window.removeEventListener('mousemove', moveHandler);
window.removeEventListener('mouseover', enterHandler as EventListener);
window.removeEventListener('scroll', scrollHandler);
window.removeEventListener('resize', resizeHandler);
window.removeEventListener('mousedown', mouseDownHandler);
window.removeEventListener('mouseup', mouseUpHandler);
if (activeTarget) {
cleanupTarget(activeTarget);
}
spinTl.current?.kill();
document.body.style.cursor = originalCursor;
isActiveRef.current = false;
targetCornerPositionsRef.current = null;
activeStrengthRef.current.current = 0;
};
}, [
targetSelector,
spinDuration,
moveCursor,
constants,
hideDefaultCursor,
isMobile,
hoverDuration,
parallaxOn,
cursorColor,
cursorColorOnTarget
]);
useEffect(() => {
if (isMobile || !cursorRef.current || !spinTl.current) return;
if (spinTl.current.isActive()) {
spinTl.current.kill();
spinTl.current = gsap
.timeline({ repeat: -1 })
.to(cursorRef.current, { rotation: '+=360', duration: spinDuration, ease: 'none' });
}
}, [spinDuration, isMobile]);
if (isMobile || typeof document === 'undefined') {
return null;
}
return createPortal(
<div
ref={cursorRef}
className="fixed top-0 left-0 w-0 h-0 pointer-events-none z-[2147483647]"
style={{ willChange: 'transform' }}
>
<div
ref={dotRef}
className="absolute top-1/2 left-1/2 w-1 h-1 rounded-full -translate-x-1/2 -translate-y-1/2"
style={{ willChange: 'transform', backgroundColor: cursorColor }}
/>
<div
className="target-cursor-corner absolute top-1/2 left-1/2 w-3 h-3 border-[3px] -translate-x-[150%] -translate-y-[150%] border-r-0 border-b-0"
style={{ willChange: 'transform', borderColor: cursorColor }}
/>
<div
className="target-cursor-corner absolute top-1/2 left-1/2 w-3 h-3 border-[3px] translate-x-1/2 -translate-y-[150%] border-l-0 border-b-0"
style={{ willChange: 'transform', borderColor: cursorColor }}
/>
<div
className="target-cursor-corner absolute top-1/2 left-1/2 w-3 h-3 border-[3px] translate-x-1/2 translate-y-1/2 border-l-0 border-t-0"
style={{ willChange: 'transform', borderColor: cursorColor }}
/>
<div
className="target-cursor-corner absolute top-1/2 left-1/2 w-3 h-3 border-[3px] -translate-x-[150%] translate-y-1/2 border-r-0 border-t-0"
style={{ willChange: 'transform', borderColor: cursorColor }}
/>
</div>,
document.body
);
};
export default TargetCursor;