Cursor Trail
Move the pointer over an area and it sheds a trail of little shapes in rotating colours, each popping out, spinning off to one side and fading as it sinks. Circles, stars, hearts, squares or a mix of them all.
Move your pointer
How to use it
- Paste the snippet just before
</head>. - Add
data-cursor-trailto the section the trail should appear in.
Options
Add any of these attributes to the same element.
data-shape | circle (default), star, heart, square or mix. |
data-colors | Comma-separated colours the shapes take in turn. Default purple, pink, sky blue and amber. |
data-size | Shape size, in px. Default 12. |
data-spacing | How far the pointer travels between shapes, in px. Default 16. |
data-life | How long each shape lasts. Default 900 ms. |
data-autoplay | A pretend pointer loops around the area while a real one isn’t in it. Handy for demos and touch screens. |
The shapes sit over the area without catching clicks.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Cursor Trail: as the pointer moves across an area it sheds a trail of
// little shapes that pop out, drift, spin and fade away behind it.
// Usage: <section data-cursor-trail>…</section>
// Optional: data-shape ("circle" (default), "star", "heart", "square" or
// "mix"), data-colors (comma-separated, default purple, pink, sky blue and
// amber), data-size (px, default 12), data-spacing (px the pointer travels
// between shapes, default 16), data-life (ms each shape lasts, default 900),
// data-autoplay (a pretend pointer loops around the area while no real one
// is in it; handy for demos and touch screens).
// The shapes sit over the area without catching clicks.
var DEFAULTS = { colors: '#a855f7,#ec4899,#38bdf8,#fbbf24', size: 12, spacing: 16, life: 900, max: 70 };
var SHAPES = {
circle: '<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="5"/></svg>',
square: '<svg viewBox="0 0 10 10"><rect width="10" height="10" rx="2.5"/></svg>',
star: '<svg viewBox="0 0 10 10"><path d="M5 0l1.5 3.4 3.5.4-2.6 2.4.8 3.6L5 8 1.8 9.8l.8-3.6L0 3.8l3.5-.4z"/></svg>',
heart: '<svg viewBox="0 0 10 10"><path d="M5 9.2C1.6 6.8 0 5.1 0 3.1 0 1.5 1.2.4 2.6.4c1 0 1.9.5 2.4 1.4C5.5.9 6.4.4 7.4.4 8.8.4 10 1.5 10 3.1c0 2-1.6 3.7-5 6.1z"/></svg>'
};
var css =
'.ctr-layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;border-radius:inherit}' +
'.ctr-bit{position:absolute;left:0;top:0;translate:-50% -50%}' +
'.ctr-bit svg{display:block;width:100%;height:100%;fill:currentColor}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function setup(el) {
var d = el.dataset;
var o = {
shape: d.shape || 'circle',
colors: (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); }),
size: +d.size || DEFAULTS.size,
spacing: +d.spacing || DEFAULTS.spacing,
life: +d.life || DEFAULTS.life
};
var names = Object.keys(SHAPES);
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var layer = document.createElement('div');
layer.className = 'ctr-layer';
layer.setAttribute('aria-hidden', 'true');
el.appendChild(layer);
var last = null, count = 0, live = 0;
// One shape at (x, y) in the layer, heading the way the pointer went.
function spawn(x, y, dx, dy) {
if (live >= DEFAULTS.max) return;
live++;
var shape = o.shape === 'mix' ? names[count % names.length] : SHAPES[o.shape] ? o.shape : 'circle';
var bit = document.createElement('span');
bit.className = 'ctr-bit';
bit.innerHTML = SHAPES[shape];
var s = o.size * (0.6 + Math.random() * 0.8);
bit.style.width = bit.style.height = s + 'px';
bit.style.color = o.colors[count++ % o.colors.length];
layer.appendChild(bit);
// Fling it sideways off the path, carry on a little and sink.
var len = Math.hypot(dx, dy) || 1;
var side = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 22);
var tx = x - (dx / len) * 10 + (-dy / len) * side;
var ty = y - (dy / len) * 10 + (dx / len) * side + 18 + Math.random() * 14;
anime.animate(bit, {
x: [x, tx],
y: [y, ty],
rotate: [Math.random() * 90 - 45, Math.random() * 360 - 180],
duration: o.life,
ease: 'out(3)'
});
anime.animate(bit, {
scale: [{ from: 0, to: 1.15, duration: o.life * 0.18, ease: 'out(3)' }, { to: 0, duration: o.life * 0.82, ease: 'in(2)' }],
opacity: [{ from: 1, to: 1, duration: o.life * 0.5 }, { to: 0, duration: o.life * 0.5, ease: 'linear' }],
onComplete: function () { bit.remove(); live--; }
});
}
// Drop shapes every so many px along the way, so fast moves don't leave gaps.
function moveTo(cx, cy) {
var r = layer.getBoundingClientRect();
var x = cx - r.left, y = cy - r.top;
if (!last) { last = { x: x, y: y }; return; }
var dx = x - last.x, dy = y - last.y;
var dist = Math.hypot(dx, dy);
if (dist > 300) { last = { x: x, y: y }; return; } // jumped: start afresh
var n = Math.floor(dist / o.spacing);
for (var i = 1; i <= n; i++) spawn(last.x + dx * i / n, last.y + dy * i / n, dx, dy);
if (n) last = { x: x, y: y };
}
var real = false;
el.addEventListener('pointermove', function (e) {
real = true;
moveTo(e.clientX, e.clientY);
});
el.addEventListener('pointerleave', function () { real = false; last = null; });
// A pretend pointer that loops in little curls around the area.
if (d.autoplay !== undefined && d.autoplay !== 'false') {
var t = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
if (real) return;
t += 0.022;
var b = el.getBoundingClientRect();
var x = b.width * (0.5 + 0.34 * Math.sin(t * 0.8)) + Math.min(b.width, b.height) * 0.12 * Math.cos(t * 3.1);
var y = b.height * (0.5 + 0.22 * Math.sin(t * 1.3)) + Math.min(b.width, b.height) * 0.12 * Math.sin(t * 3.1);
moveTo(b.left + x, b.top + y);
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play(); else { timer.pause(); last = null; }
}).observe(el);
}
}
function init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('[data-cursor-trail]').forEach(function (el) {
if (el._ctr) return;
el._ctr = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-cursor-trail>…</section>
<!-- hearts in your colours: -->
<section data-cursor-trail data-shape="heart" data-colors="#ec4899,#f43f5e">…</section>