Cursor Label
A small dot follows the pointer around. Over chosen items it swells into a circle with a word inside, like “View”, “Play” or “Drag”, then shrinks back to a dot when you move off.
How to use it
- Paste the snippet just before
</head>. - Add
data-cursor-labelto the section the dot should follow the pointer in. - Give each item that should show a label a
data-label, such asdata-label="View".
Options
Add any of these attributes to the same element.
data-color | Colour of the circle. Default #6a0dad. |
data-text-color | Colour of the label. Default white. |
data-size | Width of the full circle, in px. Default 84. |
data-hide-cursor | Hide the real cursor, on mouse and trackpad only. |
data-autoplay | A pretend pointer visits each labelled item while a real one isn’t in the section. Handy for demos and touch screens. |
The circle sits over the section without catching clicks, so links underneath still work. Keep labels short: one word fits best. On touch screens a tap shows the label under your finger for a moment.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Cursor Label: a small dot follows the pointer around an area. Over
// chosen items it swells into a circle with a word inside, like "View" or
// "Drag", then shrinks back to a dot when you move off.
// Usage: <section data-cursor-label>
// <a href="/work/1" data-label="View">…</a>
// <div class="slider" data-label="Drag">…</div>
// </section>
// Optional: data-color (circle colour, default #6a0dad), data-text-color
// (label colour, default #fff), data-size (circle width, px, default 84),
// data-hide-cursor (hide the real cursor, on mouse and trackpad only),
// data-autoplay (a pretend pointer visits each labelled item while a real
// one isn't in the area; handy for demos and touch screens).
// The circle sits over the area without catching clicks.
var DEFAULTS = { color: '#6a0dad', textColor: '#fff', size: 84 };
var css =
'.cul{position:relative}' +
'.cul-layer{position:absolute;inset:0;z-index:5;pointer-events:none;overflow:hidden;border-radius:inherit}' +
'.cul-dot{position:absolute;left:0;top:0;display:grid;place-items:center;border-radius:50%;opacity:0;' +
'transform:translate(-50%,-50%) scale(.14);transition:transform .45s cubic-bezier(.2,.9,.3,1.2),opacity .3s;will-change:transform,left,top}' +
'.cul-on .cul-dot{opacity:1}' +
'.cul-dot.cul-big{transform:translate(-50%,-50%) scale(1)}' +
'.cul-text{font:700 15px/1 system-ui,sans-serif;letter-spacing:-.01em;opacity:0;transform:scale(.6);transition:opacity .2s,transform .3s}' +
'.cul-big .cul-text{opacity:1;transform:none;transition-delay:.08s}' +
'@media (hover:hover) and (pointer:fine){.cul-nocursor,.cul-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 num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }
function setup(el) {
var d = el.dataset;
var size = num(d.size, DEFAULTS.size);
var auto = d.autoplay !== undefined && d.autoplay !== 'false';
var still = reduced();
el.classList.add('cul');
if (d.hideCursor !== undefined && d.hideCursor !== 'false') el.classList.add('cul-nocursor');
var layer = document.createElement('div');
layer.className = 'cul-layer';
layer.setAttribute('aria-hidden', 'true');
var dot = document.createElement('span');
dot.className = 'cul-dot';
dot.style.width = dot.style.height = size + 'px';
dot.style.background = d.color || DEFAULTS.color;
dot.style.color = d.textColor || DEFAULTS.textColor;
if (still) dot.style.transition = 'none';
var text = document.createElement('span');
text.className = 'cul-text';
dot.appendChild(text);
layer.appendChild(dot);
el.appendChild(layer);
var cur = { x: 0, y: 0 }, goal = { x: 0, y: 0 }, hover = false, hold = 0, visible = false, raf = 0, placed = false;
var label = null, tour = -1, nextStop = 0, rest = false;
function items() {
return Array.prototype.filter.call(el.querySelectorAll('[data-label]'), function (t) { return t.offsetWidth; });
}
function setLabel(t) {
if (t === label) return;
label = t;
if (t) text.textContent = t.getAttribute('data-label');
dot.classList.toggle('cul-big', !!t);
}
function local(cx, cy) {
var b = el.getBoundingClientRect();
return { x: cx - b.left, y: cy - b.top };
}
function aim(cx, cy) {
var p = local(cx, cy);
goal.x = p.x; goal.y = p.y;
if (!placed || still) { cur.x = p.x; cur.y = p.y; placed = true; }
var hit = document.elementFromPoint(cx, cy);
var t = hit && hit.closest ? hit.closest('[data-label]') : null;
setLabel(t && el.contains(t) ? t : null);
wake();
}
function frame(now) {
raf = 0;
if (!visible) return;
var touchHeld = Date.now() < hold;
var fake = auto && !hover && !touchHeld;
if (fake) {
// Visit each labelled item, with a pass through open space between.
var list = items(), b = el.getBoundingClientRect();
if (!placed) { cur.x = goal.x = b.width / 2; cur.y = goal.y = b.height / 2; placed = true; }
if (now >= nextStop && list.length) {
tour = (tour + 1) % (list.length + 1);
var t = list[tour];
if (t) {
var r = t.getBoundingClientRect();
goal.x = r.left - b.left + r.width * (0.35 + Math.random() * 0.3);
goal.y = r.top - b.top + r.height * (0.35 + Math.random() * 0.3);
} else {
goal.x = b.width * (0.2 + Math.random() * 0.6);
goal.y = Math.random() < 0.5 ? b.height * 0.06 : b.height * 0.94;
}
nextStop = now + (t ? 1600 : 900);
setLabel(null);
}
// The label pops up once the pretend pointer arrives.
if (tour < list.length && Math.abs(goal.x - cur.x) + Math.abs(goal.y - cur.y) < 12) setLabel(list[tour]);
}
var lag = still ? 1 : 0.16;
cur.x += (goal.x - cur.x) * lag;
cur.y += (goal.y - cur.y) * lag;
el.classList.toggle('cul-on', hover || touchHeld || fake || rest);
dot.style.left = cur.x.toFixed(1) + 'px';
dot.style.top = cur.y.toFixed(1) + 'px';
if (!hover && !touchHeld && !fake && !rest) setLabel(null);
var moving = Math.abs(goal.x - cur.x) + Math.abs(goal.y - cur.y) > 0.3;
if (fake || moving || touchHeld) raf = requestAnimationFrame(frame);
}
function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
hover = true;
aim(e.clientX, e.clientY);
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') return;
hover = true;
aim(e.clientX, e.clientY);
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
hover = false;
wake();
});
// Touch: a tap shows the label where your finger lands for a moment.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
hold = Date.now() + 1600;
placed = false;
aim(e.clientX, e.clientY);
});
if (still && auto) {
// Reduced motion: rest the label on the first item instead of touring.
auto = false;
var first = items()[0];
if (first) {
var fr = first.getBoundingClientRect();
rest = true;
aim(fr.left + fr.width / 2, fr.top + fr.height / 2);
setLabel(first);
}
}
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) wake();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-cursor-label]').forEach(function (el) {
if (el._cul) return;
el._cul = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-cursor-label data-hide-cursor>
<a href="/work/harbour" data-label="View">…</a>
<a href="/reel" data-label="Play">…</a>
<div class="carousel" data-label="Drag">…</div>
</section>