Custom Cursor
Inside an area, the pointer becomes a small dot with a ring trailing after it on a spring. Over a link the ring swells into a disc that inverts whatever is beneath it, an element with data-cursor="View" gets a bigger disc with that word in it, and pressing squeezes it.
How to use it
- Paste the snippet just before
</head>. - Add
data-custom-cursorto the section it should cover, or to<body>for the whole page. - Optionally give images or cards a word to show, e.g.
data-cursor="View".
Options
Add any of these attributes to the same element.
data-color | Cursor colour. By default it’s white and inverts what’s under it, so it shows up on light and dark alike. |
data-size | Ring size, in px. Default 36. |
data-autoplay | A pretend pointer drifts between the links while a real one isn’t in the area. Handy for demos. |
It reacts to links, buttons, form fields and anything with data-cursor. Touch screens keep their normal behaviour.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Custom Cursor: inside an area, the pointer becomes a small dot with a ring
// that trails behind it. Over links and buttons the ring swells into a disc
// and the dot tucks away; give an element data-cursor="View" and the disc
// grows bigger still and shows that word. Pressing squeezes the ring.
// Usage: <section data-custom-cursor>…links, buttons, images…</section>
// or: <body data-custom-cursor> for the whole page
// Optional: data-color (cursor colour; by default it is white and inverts
// whatever is under it), data-size (ring size in px, default 36),
// data-autoplay (a pretend pointer wanders over the links while no real
// one is in the area; handy for demos).
// Touch screens keep their normal behaviour.
var DEFAULTS = { size: 36 };
var TARGETS = 'a, button, input, select, textarea, label, summary, [role="button"], [data-cursor]';
var css =
'.ccr-hide, .ccr-hide *{cursor:none !important}' +
'.ccr-layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;border-radius:inherit}' +
'.ccr-dot, .ccr-ring{position:absolute;left:0;top:0;pointer-events:none;border-radius:50%;opacity:0;translate:-50% -50%}' +
'.ccr-fixed{position:fixed;z-index:2147483647}' +
'.ccr-dot{width:8px;height:8px;background:var(--ccr-color)}' +
'.ccr-ring{display:grid;place-items:center;border:1.5px solid var(--ccr-color);color:#000;' +
'font:800 15px/1 system-ui,sans-serif;letter-spacing:.01em;white-space:nowrap}' +
'.ccr-blend{mix-blend-mode:difference}' +
'.ccr-ring span{opacity:0}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function setup(el) {
var d = el.dataset;
var size = +d.size || DEFAULTS.size;
var page = el === document.body || el === document.documentElement;
var fine = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
if (!fine && !autoplay) return;
// On the whole page the cursor is fixed to the window; in an area it lives
// in a layer over the area, so it never escapes it.
var host = document.body;
if (!page) {
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
host = document.createElement('div');
host.className = 'ccr-layer';
host.setAttribute('aria-hidden', 'true');
el.appendChild(host);
}
var dot = document.createElement('div');
var ring = document.createElement('div');
var label = document.createElement('span');
dot.className = 'ccr-dot';
ring.className = 'ccr-ring';
ring.appendChild(label);
[dot, ring].forEach(function (n) {
n.setAttribute('aria-hidden', 'true');
n.style.setProperty('--ccr-color', d.color || '#fff');
if (!d.color) n.classList.add('ccr-blend');
if (page) n.classList.add('ccr-fixed');
host.appendChild(n);
});
ring.style.width = ring.style.height = size + 'px';
// The dot keeps up with the pointer; the ring lags behind on a spring.
var dotPos = anime.createAnimatable(dot, { x: 60, y: 60, ease: 'out(2)' });
var ringPos = anime.createAnimatable(ring, { x: 420, y: 420, ease: 'out(3)' });
var shown = false, hot = null, pressed = false, real = false;
function show(on) {
if (on === shown) return;
shown = on;
anime.animate([dot, ring], { opacity: on ? 1 : 0, duration: 250, ease: 'out(2)' });
}
// Grow over a link, bigger still with a word, back to a ring elsewhere.
function target(t) {
if (t === hot) return;
hot = t;
var word = t && t.dataset.cursor ? t.dataset.cursor : '';
if (word) label.textContent = word;
var s = word ? size * 2.4 : t ? size * 1.7 : size;
anime.animate(ring, {
width: s,
height: s,
backgroundColor: t ? (d.color || '#fff') : 'rgba(255, 255, 255, 0)',
duration: 500,
ease: 'outElastic(1, .7)'
});
anime.animate(label, { opacity: word ? 1 : 0, scale: word ? [0.6, 1] : 0.6, duration: 300, ease: 'out(3)' });
anime.animate(dot, { scale: t ? 0 : 1, duration: 250, ease: 'out(3)' });
}
function press(down) {
pressed = down;
anime.animate(ring, { scale: down ? 0.78 : 1, duration: down ? 140 : 600, ease: down ? 'out(3)' : 'outElastic(1, .5)' });
}
// Move the cursor to (x, y) on screen, and check what's under it.
function moveTo(x, y, under) {
var r = page ? { left: 0, top: 0 } : host.getBoundingClientRect();
// (Always pass the duration: an animatable keeps the last one it was given.)
dotPos.x(x - r.left, 60);
dotPos.y(y - r.top, 60);
ringPos.x(x - r.left, 420);
ringPos.y(y - r.top, 420);
var t = under && under.closest ? under.closest(TARGETS) : null;
target(t && el.contains(t) && t !== el ? t : null);
show(true);
}
if (fine) {
el.classList.add('ccr-hide');
el.addEventListener('pointerenter', function (e) {
if (e.pointerType !== 'mouse') return;
real = true;
if (wander) wander.pause();
// Jump straight to the pointer rather than flying in from the last spot.
var r = page ? { left: 0, top: 0 } : host.getBoundingClientRect();
dotPos.x(e.clientX - r.left, 0); dotPos.y(e.clientY - r.top, 0);
ringPos.x(e.clientX - r.left, 0); ringPos.y(e.clientY - r.top, 0);
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType !== 'mouse') return;
real = true;
moveTo(e.clientX, e.clientY, e.target);
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType !== 'mouse') return;
real = false;
target(null);
if (pressed) press(false);
if (wander && visible) wander.play(); else show(false);
});
el.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse') press(true); });
el.addEventListener('pointerup', function (e) { if (e.pointerType === 'mouse') press(false); });
}
// A pretend pointer that drifts from link to link, pausing and clicking.
var wander = null, visible = false, stop = 0;
var pos = { x: 0.5, y: 0.5 };
function follow() {
if (real) return;
var box = el.getBoundingClientRect();
var x = box.left + box.width * pos.x, y = box.top + box.height * pos.y;
// What's under it (the cursor itself lets clicks through, so it's skipped).
var under = x >= 0 && y >= 0 && x < innerWidth && y < innerHeight ? document.elementFromPoint(x, y) : null;
moveTo(x, y, under);
}
// Two links, the second one clicked, then a breather somewhere in the open.
function plan() {
var links = Array.prototype.filter.call(el.querySelectorAll(TARGETS), function (n) { return n !== el; });
var box = el.getBoundingClientRect();
var stops = [];
for (var i = 0; i < 2 && links.length; i++) {
var r = links[stop++ % links.length].getBoundingClientRect();
stops.push({
x: (r.left + r.width * (0.35 + Math.random() * 0.3) - box.left) / box.width,
y: (r.top + r.height / 2 - box.top) / box.height,
click: i === 1
});
}
stops.push({ x: 0.15 + Math.random() * 0.7, y: 0.1 + Math.random() * 0.15 });
wander = anime.createTimeline({
autoplay: false,
onComplete: function () { plan(); if (visible && !real) wander.play(); }
});
var at = 0;
stops.forEach(function (s) {
wander.add(pos, { x: s.x, y: s.y, duration: 1100, ease: 'inOut(3)', onUpdate: follow }, at);
at += 1100;
if (s.click) {
wander.call(function () { press(true); }, at + 120);
wander.call(function () { press(false); }, at + 280);
}
at += 650; // linger
});
wander.call(function () {}, at);
}
if (autoplay) {
plan();
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (real) return;
if (visible) wander.play(); else wander.pause();
}).observe(el);
}
}
function init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-custom-cursor]').forEach(function (el) {
if (el._ccr) return;
el._ccr = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-custom-cursor>
<a href="/work">Work</a>
<img src="project.jpg" alt="Project" data-cursor="View">
</section>
<!-- or the whole page: -->
<body data-custom-cursor>