Crosshair Cursor
Cursors & Pointer

Crosshair Cursor

A CAD-style precision crosshair spanning the surface with a live tabular x/y readout chip and optional grid snapping.

Install

npx shadcn@latest add @paragon/crosshair-cursor

crosshair-cursor.tsx

"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>
  );
}