← Hover & cursor

Crosshair Cursor

Two hairlines cross the whole area at the pointer, like the guides in a design tool, with a little tag showing the live x and y coordinates. Ticks on the top and left edges mark where the lines land.

Measure twiceCut once

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-crosshair-cursor to a section, or to <body> for a crosshair over the whole page.

Options

Add any of these attributes to the same element.

data-colorColour of the lines and tag. Default #6a0dad.
data-unitspx (default) or % to show the position as a percentage of the area.
data-hide-cursorHide the real cursor, on mouse and trackpad only.
data-autoplayA pretend pointer hops from spot to spot while a real one isn’t in the area. Handy for demos and touch screens.

The crosshair sits over the page without catching clicks. On <body> it’s fixed to the window and the coordinates are window coordinates. On touch screens, drag a finger to move it. With reduced motion turned on, it jumps straight to the pointer.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Crosshair Cursor: two hairlines cross the whole area at the pointer,
    // like a design tool's guides, with a little tag showing the live x and
    // y coordinates. Ticks on the top and left edges mark where the lines land.
    // Usage: <section data-crosshair-cursor>…</section>
    //        or <body data-crosshair-cursor> for a crosshair over the whole page.
    // Optional: data-color (line colour, default #6a0dad), data-units ("px",
    // the default, or "%" to show the position as a percentage of the area),
    // data-hide-cursor (hide the real cursor, on mouse and trackpad only),
    // data-autoplay (a pretend pointer hops from spot to spot while a real one
    // isn't in the area; handy for demos and touch screens).
    // The crosshair sits over the area without catching clicks. On touch
    // screens, drag a finger to move it.
    var DEFAULTS = { color: '#6a0dad', units: 'px' };

    var css =
      '.crh{position:relative}' +
      '.crh:not(body){overflow:hidden}' +
      '.crh-layer{position:absolute;inset:0;z-index:5;pointer-events:none;opacity:0;transition:opacity .25s;color:var(--crh-c)}' +
      'body > .crh-layer{position:fixed}' +
      '.crh-on > .crh-layer{opacity:1}' +
      '.crh-x,.crh-y{position:absolute;left:0;top:0;background:currentColor;opacity:.7}' +
      '.crh-x{width:100%;height:1px}' +
      '.crh-y{width:1px;height:100%}' +
      '.crh-ring{position:absolute;left:-9px;top:-9px;width:19px;height:19px;border:1.5px solid currentColor;border-radius:50%;box-sizing:border-box}' +
      '.crh-tag{position:absolute;left:0;top:0;padding:3px 7px;border-radius:6px;background:currentColor;font:600 11px/1.3 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap}' +
      '.crh-tag span{color:#fff}' +
      '.crh-tick{position:absolute;left:0;top:0;background:currentColor}' +
      '.crh-tick-x{width:3px;height:9px;margin-left:-1px}' +
      '.crh-tick-y{width:9px;height:3px;margin-top:-1px}' +
      '@media (hover:hover) and (pointer:fine){.crh-nocursor,.crh-nocursor *{cursor:none}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var page = el === document.body;
      var pct = (d.units || DEFAULTS.units) === '%';
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var still = reduced();
      el.classList.add('crh');
      el.style.setProperty('--crh-c', d.color || DEFAULTS.color);
      if (d.hideCursor !== undefined && d.hideCursor !== 'false') el.classList.add('crh-nocursor');

      var layer = document.createElement('div');
      layer.className = 'crh-layer';
      layer.setAttribute('aria-hidden', 'true');
      layer.innerHTML =
        '<span class="crh-x"></span><span class="crh-y"></span>' +
        '<span class="crh-tick crh-tick-x"></span><span class="crh-tick crh-tick-y"></span>' +
        '<span class="crh-ring"></span><span class="crh-tag"><span></span></span>';
      el.appendChild(layer);
      var lx = layer.querySelector('.crh-x'), ly = layer.querySelector('.crh-y');
      var tx = layer.querySelector('.crh-tick-x'), ty = layer.querySelector('.crh-tick-y');
      var ring = layer.querySelector('.crh-ring'), tag = layer.querySelector('.crh-tag'), text = tag.firstChild;

      var w = 0, h = 0;
      function measure() {
        w = page ? window.innerWidth : el.clientWidth;
        h = page ? window.innerHeight : el.clientHeight;
      }
      measure();
      if (page) window.addEventListener('resize', measure);
      else new ResizeObserver(measure).observe(el);

      var cur = { x: 0, y: 0 }, goal = { x: 0, y: 0 }, hover = false, hold = 0, visible = page, raf = 0, placed = false;
      var hop = 0, nextHop = 0, from = { x: 0, y: 0 }, to = { x: 0, y: 0 }, hopStart = 0;

      function local(cx, cy) {
        if (page) return { x: cx, y: cy };
        var b = el.getBoundingClientRect();
        return { x: cx - b.left, y: cy - b.top };
      }
      function aim(cx, cy, snap) {
        var p = local(cx, cy);
        goal.x = p.x; goal.y = p.y;
        if (!placed || snap || still) { cur.x = p.x; cur.y = p.y; placed = true; }
        wake();
      }

      function render() {
        var x = Math.round(cur.x), y = Math.round(cur.y);
        lx.style.transform = 'translateY(' + y + 'px)';
        ly.style.transform = 'translateX(' + x + 'px)';
        tx.style.transform = 'translateX(' + x + 'px)';
        ty.style.transform = 'translateY(' + y + 'px)';
        ring.style.transform = 'translate(' + x + 'px,' + y + 'px)';
        text.textContent = pct
          ? 'x ' + Math.round(x / Math.max(1, w) * 100) + '%  y ' + Math.round(y / Math.max(1, h) * 100) + '%'
          : 'x ' + x + '  y ' + y;
        // The tag sits below-right of the crossing and flips near the edges.
        var tw = tag.offsetWidth, th = tag.offsetHeight;
        var px = x + 14 + tw > w - 4 ? x - 14 - tw : x + 14;
        var py = y + 14 + th > h - 4 ? y - 14 - th : y + 14;
        tag.style.transform = 'translate(' + px + 'px,' + py + 'px)';
      }

      function ease(p) { return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2; }

      function frame(now) {
        raf = 0;
        if (!visible) return;
        var fake = auto && !hover && Date.now() > hold;
        if (fake) {
          // Glide to a spot, pause there as if measuring, then move on.
          if (!placed) { cur.x = goal.x = w * 0.5; cur.y = goal.y = h * 0.5; placed = true; }
          if (now >= nextHop) {
            from.x = cur.x; from.y = cur.y;
            to.x = w * (0.14 + Math.random() * 0.72);
            to.y = h * (0.16 + Math.random() * 0.68);
            hopStart = now; hop = 700 + Math.random() * 300;
            nextHop = now + hop + 650 + Math.random() * 500;
          }
          var p = Math.min(1, (now - hopStart) / hop), e = ease(p);
          goal.x = cur.x = from.x + (to.x - from.x) * e;
          goal.y = cur.y = from.y + (to.y - from.y) * e;
        } else {
          cur.x += (goal.x - cur.x) * 0.45;
          cur.y += (goal.y - cur.y) * 0.45;
        }
        el.classList.toggle('crh-on', hover || fake || Date.now() < hold);
        render();
        var settling = Math.abs(goal.x - cur.x) + Math.abs(goal.y - cur.y) > 0.3;
        if (fake || settling || Date.now() < hold) raf = requestAnimationFrame(frame);
      }
      function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }

      var target = page ? document : el;
      target.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') hold = Date.now() + 2500;
        else hover = true;
        aim(e.clientX, e.clientY, false);
      });
      target.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 2500;
        aim(e.clientX, e.clientY, true);
      });
      if (page) {
        document.documentElement.addEventListener('pointerleave', function () { hover = false; wake(); });
      } else {
        el.addEventListener('pointerenter', function (e) {
          if (e.pointerType === 'touch') return;
          hover = true;
          aim(e.clientX, e.clientY, true);
        });
        el.addEventListener('pointerleave', function (e) {
          if (e.pointerType === 'touch') return;
          hover = false;
          wake();
        });
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible) wake();
        }).observe(el);
      }
      if (still && auto) {
        // Reduced motion: show the crosshair resting in the middle.
        cur.x = goal.x = w * 0.5; cur.y = goal.y = h * 0.5; placed = true;
        el.classList.add('crh-on');
        render();
        auto = false;
      }
      wake();
    }

    function init() {
      document.querySelectorAll('[data-crosshair-cursor]').forEach(function (el) {
        if (el._crh) return;
        el._crh = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section class="canvas" data-crosshair-cursor>…</section>

<!-- over the whole page, in percent: -->
<body data-crosshair-cursor data-units="%">