← Hover & cursor

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

  1. Paste the snippet just before </head>.
  2. Add data-cursor-trail to the section the trail should appear in.

Options

Add any of these attributes to the same element.

data-shapecircle (default), star, heart, square or mix.
data-colorsComma-separated colours the shapes take in turn. Default purple, pink, sky blue and amber.
data-sizeShape size, in px. Default 12.
data-spacingHow far the pointer travels between shapes, in px. Default 16.
data-lifeHow long each shape lasts. Default 900 ms.
data-autoplayA 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.

1 · Snippet
<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>
2 · Your HTML
<section data-cursor-trail>…</section>

<!-- hearts in your colours: -->
<section data-cursor-trail data-shape="heart" data-colors="#ec4899,#f43f5e">…</section>