A CAD-style precision crosshair spanning the surface with a live tabular x/y readout chip and optional grid snapping.
npx shadcn@latest add @paragon/crosshair-cursor"use client";
import * as React from "react";
import { useMotionValue, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/**
* CrosshairCursor — a precision crosshair spanning the full surface with a live
* x/y readout in tabular-nums, optionally snapping the pointer to a grid. Reads
* like a design/CAD tool: full-bleed guide lines, a small center reticle, and a
* coordinate chip that flips to stay inside the surface near edges.
*
* Wrap any content: `<CrosshairCursor>…surface…</CrosshairCursor>`. The overlay
* is pointer-events-none and hides the native cursor within the surface. Gated
* on fine-pointer devices. Reduced motion keeps the crosshair, just without the
* enter fade. No springs — precision instruments track 1:1.
*/
export interface CrosshairCursorProps extends React.ComponentProps<"div"> {
/** Line + reticle color. Defaults to the primary token. */
color?: string;
/** Show the coordinate readout chip. */
showReadout?: boolean;
/** Snap grid size in px. 0 disables snapping. */
snap?: number;
}
export function CrosshairCursor({
color = "var(--color-primary)",
showReadout = true,
snap = 0,
className,
children,
...props
}: CrosshairCursorProps) {
const hostRef = React.useRef<HTMLDivElement>(null);
const vLineRef = React.useRef<HTMLDivElement>(null);
const hLineRef = React.useRef<HTMLDivElement>(null);
const reticleRef = React.useRef<HTMLDivElement>(null);
const chipRef = React.useRef<HTMLDivElement>(null);
const [fine, setFine] = React.useState(false);
const [inside, setInside] = React.useState(false);
const reduced = useReducedMotion();
// Store the last position so a snap change re-renders the readout via state,
// but the DOM lines move imperatively for zero-lag precision.
const posX = useMotionValue(0);
const posY = useMotionValue(0);
const [coords, setCoords] = React.useState({ x: 0, y: 0 });
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
React.useEffect(() => {
const el = hostRef.current;
if (!el || !fine) return;
const step = Math.max(0, Math.round(snap));
const apply = (rawX: number, rawY: number, w: number, h: number) => {
const gx = step > 0 ? Math.round(rawX / step) * step : rawX;
const gy = step > 0 ? Math.round(rawY / step) * step : rawY;
posX.set(gx);
posY.set(gy);
if (vLineRef.current)
vLineRef.current.style.transform = `translateX(${gx}px)`;
if (hLineRef.current)
hLineRef.current.style.transform = `translateY(${gy}px)`;
if (reticleRef.current)
reticleRef.current.style.transform = `translate(${gx}px, ${gy}px) translate(-50%, -50%)`;
if (chipRef.current) {
// Flip the chip to keep it on-surface near the right/bottom edges.
const flipX = gx > w - 92;
const flipY = gy > h - 40;
const cx = flipX ? gx - 12 : gx + 12;
const cy = flipY ? gy - 12 : gy + 12;
chipRef.current.style.transform = `translate(${cx}px, ${cy}px) translate(${flipX ? "-100%" : "0"}, ${flipY ? "-100%" : "0"})`;
}
setCoords({ x: Math.round(gx), y: Math.round(gy) });
};
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
apply(
e.clientX - rect.left,
e.clientY - rect.top,
rect.width,
rect.height,
);
};
const onEnter = () => setInside(true);
const onLeave = () => setInside(false);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("pointerleave", onLeave);
};
}, [fine, snap, posX, posY]);
return (
<div
ref={hostRef}
className={cn(
"relative overflow-hidden",
fine && "[&_*]:cursor-none",
className,
)}
style={fine ? { cursor: "none" } : undefined}
{...props}
>
{children}
{fine && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-50"
style={{
opacity: inside ? 1 : 0,
transition: reduced ? "none" : "opacity 140ms ease",
}}
>
<div
ref={vLineRef}
className="absolute top-0 left-0 h-full will-change-transform"
style={{
width: 1,
background: color,
opacity: 0.5,
}}
/>
<div
ref={hLineRef}
className="absolute top-0 left-0 w-full will-change-transform"
style={{
height: 1,
background: color,
opacity: 0.5,
}}
/>
<div
ref={reticleRef}
className="absolute top-0 left-0 rounded-full will-change-transform"
style={{
width: 10,
height: 10,
border: `1.5px solid ${color}`,
background: `color-mix(in oklch, ${color} 16%, transparent)`,
}}
/>
{showReadout && (
<div
ref={chipRef}
className="absolute top-0 left-0 rounded-md bg-popover px-1.5 py-0.5 font-mono text-[10px] leading-none text-popover-foreground shadow-overlay will-change-transform"
>
<span className="tabular-nums">{coords.x}</span>
<span className="mx-1 text-muted-foreground">×</span>
<span className="tabular-nums">{coords.y}</span>
</div>
)}
</div>
)}
</div>
);
}