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