Swish UI
All componentsCursor Free

Crosshair cursor

Thin full width and full height lines track the pointer with a live coordinate readout, and thicken when they lock onto a target.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

const TARGETS = [
  { name: "Pool", x: 24, y: 64 },
  { name: "Terrace", x: 56, y: 30 },
  { name: "Entry", x: 80, y: 70 },
];
const IDLE_MS = 2000;
const pad = (n: number) => String(Math.max(0, Math.round(n))).padStart(3, "0");

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const lineX = useRef<HTMLDivElement>(null);
  const lineY = useRef<HTMLDivElement>(null);
  const readout = useRef<HTMLDivElement>(null);
  const reduced = usePrefersReducedMotion();
  const inView = useInView(root);

  useEffect(() => {
    // Elements are read once here: a frame can still run after unmount, when refs are already null.
    const [el, lx, ly, out] = [root.current, lineX.current, lineY.current, readout.current];
    if (!el || !lx || !ly || !out || reduced || !inView) return;
    const spots = [...el.querySelectorAll<HTMLElement>("[data-target]")];
    const target = { x: el.clientWidth / 2, y: el.clientHeight / 2 };
    const pos = { ...target };
    let raf = 0, lastInput = -IDLE_MS, text = "";

    const frame = (now: number) => {
      const base = el.getBoundingClientRect();
      const boxes = spots.map((s) => {
        const r = s.getBoundingClientRect();
        return { left: r.left - base.left, top: r.top - base.top, right: r.right - base.left, bottom: r.bottom - base.top };
      });
      // Idle and touch fallback: the crosshair visits each target in turn.
      if (now - lastInput > IDLE_MS) {
        const box = boxes[Math.floor(now / 1800) % boxes.length];
        target.x = (box.left + box.right) / 2;
        target.y = (box.top + box.bottom) / 2;
      }
      pos.x += (target.x - pos.x) * 0.2;
      pos.y += (target.y - pos.y) * 0.2;
      lx.style.transform = `translateY(${pos.y}px)`;
      ly.style.transform = `translateX(${pos.x}px)`;
      out.style.transform = `translate(${pos.x + 12}px, ${pos.y + 12}px)`;
      // Lock onto a target when the lines cross inside it.
      const hit = spots.find((_, i) => pos.x >= boxes[i].left && pos.x <= boxes[i].right && pos.y >= boxes[i].top && pos.y <= boxes[i].bottom);
      el.classList.toggle(styles.locked, Boolean(hit));
      spots.forEach((s) => s.classList.toggle(styles.hit, s === hit));
      const next = `X ${pad(pos.x)}  Y ${pad(pos.y)}`;
      if (next !== text) out.textContent = text = next;
      raf = requestAnimationFrame(frame);
    };
    const point = (event: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      target.x = event.clientX - rect.left;
      target.y = event.clientY - rect.top;
      lastInput = performance.now();
    };
    const move = (event: PointerEvent) => {
      if (event.pointerType !== "touch") point(event);
    };
    // A tap moves the crosshair there; the tour resumes after a pause.
    const down = (event: PointerEvent) => {
      if (event.pointerType === "touch") point(event);
    };

    el.addEventListener("pointermove", move);
    el.addEventListener("pointerdown", down);
    raf = requestAnimationFrame(frame);
    return () => {
      cancelAnimationFrame(raf);
      el.removeEventListener("pointermove", move);
      el.removeEventListener("pointerdown", down);
    };
  }, [reduced, inView]);

  return (
    <div ref={root} className={`${styles.root} ${reduced ? styles.still : ""}`}>
      <div className={styles.plan} aria-hidden="true">
        <span className={styles.room} style={{ left: "8%", top: "40%", width: "34%", height: "48%" }} />
        <span className={styles.room} style={{ left: "38%", top: "12%", width: "36%", height: "38%" }} />
        <span className={styles.room} style={{ left: "66%", top: "50%", width: "26%", height: "38%" }} />
      </div>
      <div ref={lineX} className={styles.lineX} aria-hidden="true"><span /></div>
      <div ref={lineY} className={styles.lineY} aria-hidden="true"><span /></div>
      {TARGETS.map((t) => (
        <button key={t.name} type="button" data-target={t.name} className={styles.target} style={{ left: `${t.x}%`, top: `${t.y}%` }}>
          {t.name}
        </button>
      ))}
      <div ref={readout} className={styles.readout} aria-hidden="true">X 056  Y 030</div>
    </div>
  );
}

demo.module.css

.root {
  position: relative; width: 100%; height: 100%; overflow: hidden; touch-action: manipulation;
  background-color: var(--white);
  background-image: linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 1px, transparent 1px);
  background-size: 24px 24px;
}
@media (pointer: fine) { .root, .root * { cursor: none; } }
.plan { position: absolute; inset: 0; }
.room { position: absolute; border: 1.5px solid var(--muted); border-radius: 4px; opacity: .35; }
.target {
  position: absolute; translate: -50% -50%; min-height: 36px; padding: 0 14px; border-radius: 999px;
  border: 1px solid var(--line); background: var(--white); font-size: 13px; font-weight: 650;
  transition: border-color .2s, background-color .2s, color .2s;
}
.hit { border-color: var(--accent-text); background: var(--accent-pale); color: var(--accent-text); }
.lineX, .lineY { position: absolute; left: 0; top: 0; pointer-events: none; }
.lineX { width: 100%; }
.lineY { height: 100%; }
.lineX span, .lineY span { position: absolute; background: var(--ink); opacity: .7; transition: background-color .2s, opacity .2s, scale .3s var(--out); }
.lineX span { left: 0; right: 0; height: 1px; translate: 0 -50%; }
.lineY span { top: 0; bottom: 0; width: 1px; translate: -50% 0; }
/* Over a target the lines thicken and take the accent colour. */
.locked .lineX span { scale: 1 3; background: var(--accent-text); opacity: 1; }
.locked .lineY span { scale: 3 1; background: var(--accent-text); opacity: 1; }
.readout {
  position: absolute; left: 0; top: 0; padding: 4px 8px; border-radius: 6px; pointer-events: none; white-space: pre;
  background: var(--ink); color: var(--white); font: 600 11px/1.4 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
.locked .readout { background: var(--accent-text); }
/* Reduced motion: the crosshair rests on the terrace, locked, with no loop. */
.still .lineX { transform: translateY(30%); height: 100%; }
.still .lineX span { top: 0; }
.still .lineY { transform: translateX(56%); width: 100%; }
.still .lineY span { left: 0; }
.still .lineX span, .still .lineY span { background: var(--accent-text); opacity: 1; }
.still .readout { left: 56%; top: 30%; translate: 12px 22px; background: var(--accent-text); }
.still .target:nth-of-type(2) { border-color: var(--accent-text); background: var(--accent-pale); color: var(--accent-text); }
@media (prefers-reduced-motion: reduce) { .lineX span, .lineY span, .target { transition: none; } }

Use it for

  • Maps, floor plans and design tools where position matters
  • Technical or editorial brands that want a precise, measured feel

Avoid

  • Reading heavy pages, since the lines cut through text
  • Small touch first layouts, where there is no hover to track

How it works

One horizontal and one vertical line sit across the whole stage and are moved with translate in a requestAnimationFrame loop that eases toward the pointer. Each frame the eased point is tested against the target rectangles; inside one, a class scales the lines to three times their thickness, tints the readout of padded X and Y values and highlights the target. Without input the crosshair tours the targets on its own.

Accessibility

The lines and readout are aria-hidden and ignore pointer events; the targets are real buttons. The native cursor is hidden only for fine pointers. Touch users move the crosshair with a tap and see the tour otherwise. Reduced motion shows the crosshair locked on one target with no loop, and the loop pauses off screen.

Found on