Build a Target Cursor Effect in React
Replace the default cursor with a crosshair that follows mouse movement, built with smooth spring physics.
The cursor is the one thing on screen the user controls completely. Most sites ignore it and leave the default arrow. Swap that for a precision crosshair that snaps around interactive elements and you immediately change the feel of the entire interface.
The final result
What we are building
The native cursor is hidden and replaced with a GSAP-driven crosshair made of four L-shaped corner brackets. The crosshair spins continuously. When hovering over a target element, it stops spinning, expands to frame the element's corners, and tracks the element even during scroll.
Setting up
You need GSAP:
npm install gsapimport { gsap } from 'gsap';The component accepts a CSS selector to identify which elements trigger the lock-on behavior:
interface TargetCursorProps {
targetSelector?: string; // default: '.cursor-target'
spinDuration?: number; // default: 2
hideDefaultCursor?: boolean;
hoverDuration?: number;
parallaxOn?: boolean;
}Building the component
The component renders four corner brackets and a center dot, all positioned at top-0 left-0 with translate offsets:
<div
ref={cursorRef}
className='fixed top-0 left-0 w-0 h-0 pointer-events-none z-[9999]'
style={{ willChange: 'transform' }}
>
<div ref={dotRef} className='absolute top-1/2 left-1/2 w-1 h-1 bg-white rounded-full -translate-x-1/2 -translate-y-1/2' />
<div 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' />
<div 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' />
<div 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' />
<div 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' />
</div>The continuous spin is a looping GSAP timeline:
spinTl.current = gsap.timeline({ repeat: -1 }).to(cursor, {
rotation: '+=360',
duration: spinDuration,
ease: 'none',
});On mouse enter to a target element, the spin pauses and each corner bracket is animated to its matching corner of the element's bounding rect:
const rect = target.getBoundingClientRect();
const { borderWidth, cornerSize } = constants;
targetCornerPositionsRef.current = [
{ x: rect.left - borderWidth, y: rect.top - borderWidth },
{ x: rect.right + borderWidth - cornerSize, y: rect.top - borderWidth },
{ x: rect.right + borderWidth - cornerSize, y: rect.bottom + borderWidth - cornerSize },
{ x: rect.left - borderWidth, y: rect.bottom + borderWidth - cornerSize },
];The GSAP ticker keeps the corners updated relative to the cursor position while hovered, which handles parallax scrolling automatically:
const tickerFn = () => {
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;
corners.forEach((corner, i) => {
const targetX = targetCornerPositionsRef.current![i].x - cursorX;
const targetY = targetCornerPositionsRef.current![i].y - cursorY;
gsap.to(corner, { x: targetX, y: targetY, duration: parallaxOn ? 0.2 : 0 });
});
};
gsap.ticker.add(tickerFn);Mobile detection is handled up front so the component returns null and preserves the default cursor on touch devices:
const isMobile = useMemo(() => {
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const isSmallScreen = window.innerWidth <= 768;
return (hasTouchScreen && isSmallScreen) || isMobileUserAgent;
}, []);
if (isMobile) return null;How to use it
Add the component once at the app root. Mark interactive elements with your chosen selector:
<TargetCursor targetSelector=".cursor-target" spinDuration={2} />
<button className="cursor-target">Click me</button>Key takeaways
- GSAP's ticker runs outside React's render cycle, keeping corner tracking smooth even during heavy renders.
- The spin timeline normalizes its rotation before restarting after a hover ends, so it never jumps to an unexpected angle.
- The component cleans up all event listeners, ticker functions, and GSAP timelines in the
useEffectreturn to prevent memory leaks.