Custom cursor follower
A ring trails the pointer with smooth easing and grows with a label when it passes over a target.
CSSJavaScriptAdvanced
<div class="cards"><div class="card" data-label="View">One</div><div class="card" data-label="Play">Two</div><div class="card" data-label="Open">Three</div></div>
<div class="cursor" id="cur"><span id="lbl"></span></div>
body{overflow:hidden}
.cards{display:flex;gap:18px;flex-wrap:wrap;justify-content:center}
.card{width:150px;height:150px;border-radius:16px;display:grid;place-items:center;font-weight:700;color:#270100}
.card:nth-child(1){background:#f08a5d}.card:nth-child(2){background:#f3d9c8}.card:nth-child(3){background:#e8b04c}
.cursor{position:fixed;left:0;top:0;z-index:99;pointer-events:none;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;
border:2px solid #f08a5d;display:grid;place-items:center;font-size:12px;font-weight:700;color:#270100;
transition:width .3s cubic-bezier(.2,.8,.2,1),height .3s cubic-bezier(.2,.8,.2,1),margin .3s cubic-bezier(.2,.8,.2,1),background .3s;opacity:0}
.cursor.show{opacity:1}
.cursor.big{width:84px;height:84px;margin:-42px 0 0 -42px;background:#fff;border-color:#fff;mix-blend-mode:normal}
.cursor span{opacity:0;transition:opacity .2s}
.cursor.big span{opacity:1}
@media (hover:none){.cursor{display:none}}
const cur = document.getElementById('cur');
const lbl = document.getElementById('lbl');
let x = innerWidth / 2, y = innerHeight / 2, cx = x, cy = y;
addEventListener('pointermove', e => { x = e.clientX; y = e.clientY; cur.classList.add('show'); });
document.addEventListener('pointerleave', () => cur.classList.remove('show'));
(function loop() {
cx += (x - cx) * 0.18; // ease toward the pointer
cy += (y - cy) * 0.18;
cur.style.transform = `translate3d(${cx}px, ${cy}px, 0)`;
requestAnimationFrame(loop);
})();
document.querySelectorAll('[data-label]').forEach(el => {
el.addEventListener('pointerenter', () => { lbl.textContent = el.dataset.label; cur.classList.add('big'); });
el.addEventListener('pointerleave', () => cur.classList.remove('big'));
});
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Cursor follower</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{display:grid;place-items:center;background:#140807;color:#fffffc;font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
body{overflow:hidden}
.cards{display:flex;gap:18px;flex-wrap:wrap;justify-content:center}
.card{width:150px;height:150px;border-radius:16px;display:grid;place-items:center;font-weight:700;color:#270100}
.card:nth-child(1){background:#f08a5d}.card:nth-child(2){background:#f3d9c8}.card:nth-child(3){background:#e8b04c}
.cursor{position:fixed;left:0;top:0;z-index:99;pointer-events:none;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;
border:2px solid #f08a5d;display:grid;place-items:center;font-size:12px;font-weight:700;color:#270100;
transition:width .3s cubic-bezier(.2,.8,.2,1),height .3s cubic-bezier(.2,.8,.2,1),margin .3s cubic-bezier(.2,.8,.2,1),background .3s;opacity:0}
.cursor.show{opacity:1}
.cursor.big{width:84px;height:84px;margin:-42px 0 0 -42px;background:#fff;border-color:#fff;mix-blend-mode:normal}
.cursor span{opacity:0;transition:opacity .2s}
.cursor.big span{opacity:1}
@media (hover:none){.cursor{display:none}}
</style></head>
<body>
<div class="cards"><div class="card" data-label="View">One</div><div class="card" data-label="Play">Two</div><div class="card" data-label="Open">Three</div></div>
<div class="cursor" id="cur"><span id="lbl"></span></div>
<script>
const cur = document.getElementById('cur');
const lbl = document.getElementById('lbl');
let x = innerWidth / 2, y = innerHeight / 2, cx = x, cy = y;
addEventListener('pointermove', e => { x = e.clientX; y = e.clientY; cur.classList.add('show'); });
document.addEventListener('pointerleave', () => cur.classList.remove('show'));
(function loop() {
cx += (x - cx) * 0.18; // ease toward the pointer
cy += (y - cy) * 0.18;
cur.style.transform = `translate3d(${cx}px, ${cy}px, 0)`;
requestAnimationFrame(loop);
})();
document.querySelectorAll('[data-label]').forEach(el => {
el.addEventListener('pointerenter', () => { lbl.textContent = el.dataset.label; cur.classList.add('big'); });
el.addEventListener('pointerleave', () => cur.classList.remove('big'));
});
</script>
</body></html>
Good for
Portfolio and agency sites with large clickable cards. Keep the native cursor too.
Watch out for
Use pointer-events none on the cursor, hide it on touch devices, and never remove the real cursor on text or form fields.
Accessibility
Wrap motion in @media (prefers-reduced-motion: reduce) and switch it off or shorten it for people who ask for less movement.