Build a Crosshair Cursor in React with GSAP
A precision crosshair cursor with extending lines and a targeting animation, driven by GSAP for buttery-smooth motion.
GSAP has been the gold standard for web animation for years, and for good reason. When it comes to a cursor effect that needs to feel precise and immediate, GSAP's rendering pipeline delivers a smoothness that spring-based libraries struggle to match at high refresh rates.
The final result
What we are building
The default cursor is replaced with two full-width crosshair lines: one horizontal, one vertical. They follow the mouse with a slight lag using a manual lerp on every animation frame. When hovering over any link, an SVG noise filter kicks in and shakes the lines briefly, like a targeting glitch effect.
Setting up
You need GSAP:
npm install gsapimport { gsap } from 'gsap';
interface CrosshairProps {
color?: string;
containerRef?: RefObject<HTMLElement>;
}The optional containerRef scopes the crosshair to a specific element rather than the whole page.
Building the component
The crosshair renders two absolutely positioned divs: a 1px-tall div spanning full width for the horizontal line, and a 1px-wide div spanning full height for the vertical line:
<div ref={lineHorizontalRef}
className='absolute w-full h-px pointer-events-none opacity-0 transform translate-y-1/2'
style={{ background: color }}
/>
<div ref={lineVerticalRef}
className='absolute h-full w-px pointer-events-none opacity-0 transform translate-x-1/2'
style={{ background: color }}
/>Both start at opacity-0. The first mouse move triggers a GSAP tween to fade them in, and then the onMouseMove listener that triggered it removes itself so it only fires once:
const onMouseMove = (_ev: Event) => {
renderedStyles.tx.previous = renderedStyles.tx.current = mouse.x;
renderedStyles.ty.previous = renderedStyles.ty.current = mouse.y;
gsap.to([lineHorizontalRef.current, lineVerticalRef.current], {
duration: 0.9,
ease: 'Power3.easeOut',
opacity: 1,
});
requestAnimationFrame(render);
target.removeEventListener('mousemove', onMouseMove);
};The render loop uses a manual lerp to smooth the crosshair position. The amt value of 0.15 means the lines close 15% of the remaining distance each frame:
const lerp = (a: number, b: number, n: number): number => (1 - n) * a + n * b;
const render = () => {
renderedStyles.tx.current = mouse.x;
renderedStyles.ty.current = mouse.y;
for (const key in renderedStyles) {
const style = renderedStyles[key];
style.previous = lerp(style.previous, style.current, style.amt);
}
gsap.set(lineVerticalRef.current, { x: renderedStyles.tx.previous });
gsap.set(lineHorizontalRef.current, { y: renderedStyles.ty.previous });
requestAnimationFrame(render);
};The glitch effect on link hover uses a GSAP timeline that animates the baseFrequency attribute of SVG feTurbulence elements. These are embedded in the component's SVG filter definitions:
const tl = gsap.timeline({ paused: true,
onStart: () => {
lineHorizontalRef.current!.style.filter = 'url(#filter-noise-x)';
lineVerticalRef.current!.style.filter = 'url(#filter-noise-y)';
},
onUpdate: () => {
filterXRef.current!.setAttribute('baseFrequency', primitiveValues.turbulence.toString());
filterYRef.current!.setAttribute('baseFrequency', primitiveValues.turbulence.toString());
},
onComplete: () => {
lineHorizontalRef.current!.style.filter = 'none';
lineVerticalRef.current!.style.filter = 'none';
},
}).to(primitiveValues, { duration: 0.5, ease: 'power1', startAt: { turbulence: 1 }, turbulence: 0 });The enter handler restarts this timeline and the leave handler skips it to the end, ensuring the filter is always cleaned up even if the user moves fast:
const enter = () => tl.restart();
const leave = () => { tl.progress(1).kill(); };How to use it
// Full-page crosshair at the app root
<Crosshair color="white" />
// Scoped to a specific container
const ref = useRef<HTMLDivElement>(null);
<div ref={ref}>
<Crosshair color="red" containerRef={ref} />
</div>Key takeaways
- Manual lerp in a
requestAnimationFrameloop gives you precise control over lag without needing a physics simulation. Theamtvalue is intuitive: 0 is no movement, 1 is instant. - Animating SVG filter attributes directly with GSAP produces a glitch effect that CSS transitions simply cannot replicate.
- The
containerRefscope makes the component reusable for interactive regions within a page rather than forcing a full-document takeover.