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
- Paste the snippet just before
</head>. - Add
data-crosshair-cursorto a section, or to<body>for a crosshair over the whole page.
Options
Add any of these attributes to the same element.
data-color | Colour of the lines and tag. Default #6a0dad. |
data-units | px (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 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.
<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><section class="canvas" data-crosshair-cursor>…</section>
<!-- over the whole page, in percent: -->
<body data-crosshair-cursor data-units="%">