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 { 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 that
* contracts on press, 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. All
* tracking is imperative DOM writes — zero React re-renders per move — because
* precision instruments track 1:1, no springs. Gated on fine-pointer devices;
* reduced motion keeps the 1:1 crosshair (it never animates on its own) and
* only drops the enter fade.
*/
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 reticleDiscRef = React.useRef<HTMLDivElement>(null);
const chipRef = React.useRef<HTMLDivElement>(null);
const xTextRef = React.useRef<HTMLSpanElement>(null);
const yTextRef = React.useRef<HTMLSpanElement>(null);
const [fine, setFine] = React.useState(false);
const [inside, setInside] = React.useState(false);
const insideRef = React.useRef(false);
const reduced = useReducedMotion();
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));
// Chip metrics are cached and only re-measured when the digit count
// changes, so pointermove never forces a layout read.
let chipSize: { w: number; h: number; key: string } | null = null;
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;
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%)`;
const chip = chipRef.current;
if (chip && xTextRef.current && yTextRef.current) {
const xs = String(Math.round(gx));
const ys = String(Math.round(gy));
xTextRef.current.textContent = xs;
yTextRef.current.textContent = ys;
const key = `${xs.length}-${ys.length}`;
if (!chipSize || chipSize.key !== key) {
chipSize = { w: chip.offsetWidth, h: chip.offsetHeight, key };
}
// Flip the chip to keep it on-surface near the right/bottom edges.
const flipX = gx > w - (chipSize.w + 20);
const flipY = gy > h - (chipSize.h + 20);
const cx = flipX ? gx - 12 : gx + 12;
const cy = flipY ? gy - 12 : gy + 12;
chip.style.transform = `translate(${cx}px, ${cy}px) translate(${flipX ? "-100%" : "0"}, ${flipY ? "-100%" : "0"})`;
}
};
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
apply(
e.clientX - rect.left,
e.clientY - rect.top,
rect.width,
rect.height,
);
// If the pointer was already over the surface at mount, the first event
// we see is a move — treat it as the entry so the crosshair appears.
if (!insideRef.current) {
insideRef.current = true;
setInside(true);
}
};
const setDisc = (pressed: boolean) => {
if (reticleDiscRef.current)
reticleDiscRef.current.style.transform = pressed
? "scale(0.7)"
: "scale(1)";
};
const onEnter = (e: PointerEvent) => onMove(e);
const onLeave = () => {
insideRef.current = false;
setInside(false);
setDisc(false);
};
const onDown = () => setDisc(true);
const onUp = () => setDisc(false);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);
el.addEventListener("pointerdown", onDown);
el.addEventListener("pointerup", onUp);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("pointerleave", onLeave);
el.removeEventListener("pointerdown", onDown);
el.removeEventListener("pointerup", onUp);
};
}, [fine, snap]);
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 will-change-transform"
>
<div
ref={reticleDiscRef}
className="rounded-full"
style={{
width: 10,
height: 10,
border: `1.5px solid ${color}`,
background: `color-mix(in oklch, ${color} 16%, transparent)`,
transition: reduced
? "none"
: "transform 120ms var(--ease-out)",
}}
/>
</div>
{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 ref={xTextRef} className="tabular-nums">
0
</span>
<span className="mx-1 text-muted-foreground">×</span>
<span ref={yTextRef} className="tabular-nums">
0
</span>
</div>
)}
</div>
)}
</div>
);
}