← Hover & cursor

Ribbon Trail

A few springy ribbons chase the pointer around the area. Each one has its own bounce, so they swing apart on the turns and gather up again, tapering to a point at the tail.

Follow me

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-ribbon-trail to the section the ribbons should follow the pointer in.

Options

Add any of these attributes to the same element.

data-colorComma-separated colours, one ribbon each. Default purple, pink and sky blue.
data-widthHow thick each ribbon is at its head, in px. Default 12.
data-lengthHow many links make up a ribbon. More makes a longer tail. Default 26.
data-autoplayA pretend pointer swoops around while a real one isn’t in the section. Handy for demos and touch screens.

The ribbons sit over the section without catching clicks, and fade away when the pointer leaves. On touch screens they swoop over to your finger. With reduced motion turned on, there are no ribbons.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Ribbon Trail: a few springy ribbons chase the pointer around an area.
    // Each one has its own bounce, so they swing apart on the turns and
    // gather up again, tapering to a point at the tail.
    // Usage: <section data-ribbon-trail>…</section>
    // Optional: data-color (comma-separated, one ribbon per colour, default
    // purple, pink and sky blue), data-width (thickness of a ribbon's head,
    // px, default 12), data-length (how many links make up a ribbon, default
    // 26), data-autoplay (a pretend pointer swoops around while a real one
    // isn't in the area; handy for demos and touch screens).
    // The ribbons sit over the area without catching clicks.
    var DEFAULTS = { color: '#a855f7,#ec4899,#38bdf8', width: 12, length: 26 };

    var css =
      '.rbt{position:relative;overflow:hidden}' +
      '.rbt-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:1;pointer-events: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 colours = (d.color || DEFAULTS.color).split(',').map(function (c) { return c.trim(); });
      var width = num(d.width, DEFAULTS.width);
      var len = Math.max(4, Math.min(80, Math.round(num(d.length, DEFAULTS.length))));
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      el.classList.add('rbt');

      var canvas = document.createElement('canvas');
      canvas.className = 'rbt-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var w = 0, h = 0, dpr = 1;
      function size() {
        w = el.clientWidth; h = el.clientHeight;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.max(1, Math.round(w * dpr));
        canvas.height = Math.max(1, Math.round(h * dpr));
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      }
      size();
      new ResizeObserver(function () { size(); wake(); }).observe(el);

      // Each ribbon: a springy head with its own stiffness and damping, and a
      // chain of links that each ease towards the one in front.
      var ribbons = colours.map(function (c, i) {
        var pts = [];
        for (var k = 0; k < len; k++) pts.push({ x: 0, y: 0 });
        return {
          colour: c, pts: pts, vx: 0, vy: 0,
          stiff: Math.min(0.3, 0.035 + i * 0.045), damp: Math.max(0.6, 0.9 - i * 0.06),
          follow: Math.max(0.18, 0.32 - i * 0.04), w: width * Math.max(0.5, 1 - i * 0.15),
          // Each head aims a little to one side of the pointer, so the ribbons fan out.
          off: i * Math.PI * 2 / colours.length
        };
      });

      var pt = { x: 0, y: 0 }, hover = false, hold = 0, visible = false, raf = 0, t = Math.random() * 6, k = 0, placed = false, still = 0;
      function place() {
        placed = true;
        ribbons.forEach(function (rb) {
          rb.vx = rb.vy = 0;
          rb.pts.forEach(function (q) { q.x = pt.x; q.y = pt.y; });
        });
      }
      function aim(cx, cy) {
        var b = el.getBoundingClientRect();
        pt.x = cx - b.left; pt.y = cy - b.top;
        if (!placed || k < 0.05) place();
        wake();
      }

      function drawRibbon(rb, alpha) {
        var pts = rb.pts, n = pts.length;
        var left = [], right = [];
        for (var i = 0; i < n; i++) {
          var a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
          var dx = b.x - a.x, dy = b.y - a.y, l = Math.sqrt(dx * dx + dy * dy) || 1;
          // Thick at the head, tapering to a point at the tail.
          var hw = rb.w / 2 * Math.pow(1 - i / (n - 1), 0.9) * alpha;
          left.push([pts[i].x - dy / l * hw, pts[i].y + dx / l * hw]);
          right.push([pts[i].x + dy / l * hw, pts[i].y - dx / l * hw]);
        }
        ctx.beginPath();
        ctx.moveTo(left[0][0], left[0][1]);
        for (var j = 1; j < n - 1; j++) {
          ctx.quadraticCurveTo(left[j][0], left[j][1], (left[j][0] + left[j + 1][0]) / 2, (left[j][1] + left[j + 1][1]) / 2);
        }
        ctx.lineTo(left[n - 1][0], left[n - 1][1]);
        ctx.lineTo(right[n - 1][0], right[n - 1][1]);
        for (var m = n - 2; m > 0; m--) {
          ctx.quadraticCurveTo(right[m][0], right[m][1], (right[m][0] + right[m - 1][0]) / 2, (right[m][1] + right[m - 1][1]) / 2);
        }
        ctx.lineTo(right[0][0], right[0][1]);
        ctx.closePath();
        ctx.fillStyle = rb.colour;
        ctx.fill();
        // A round cap on the head.
        ctx.beginPath();
        ctx.arc(pts[0].x, pts[0].y, rb.w / 2 * alpha, 0, Math.PI * 2);
        ctx.fill();
      }

      function frame() {
        raf = 0;
        if (!visible) return;
        var touchHeld = Date.now() < hold;
        var fake = auto && !hover && !touchHeld;
        if (fake) {
          // Loops and figure-of-eights, with a quick swoop now and then.
          t += 0.014 + 0.03 * Math.pow(Math.max(0, Math.sin(t * 0.5)), 6);
          pt.x = w * (0.5 + 0.36 * Math.sin(t * 1.4));
          pt.y = h * (0.5 + 0.3 * Math.sin(t * 2.3 + 0.9));
          if (!placed) place();
        }
        var on = hover || touchHeld || fake;
        k += ((on ? 1 : 0) - k) * 0.1;

        var moving = 0;
        ctx.clearRect(0, 0, w, h);
        ctx.globalAlpha = Math.min(1, k * 1.2);
        var spin = performance.now() / 900, spread = width * 0.7;
        for (var r = 0; r < ribbons.length; r++) {
          var rb = ribbons[r], head = rb.pts[0];
          var ox = r ? Math.cos(rb.off + spin) * spread : 0, oy = r ? Math.sin(rb.off + spin) * spread : 0;
          rb.vx = (rb.vx + (pt.x + ox - head.x) * rb.stiff) * rb.damp;
          rb.vy = (rb.vy + (pt.y + oy - head.y) * rb.stiff) * rb.damp;
          head.x += rb.vx; head.y += rb.vy;
          for (var i = 1; i < rb.pts.length; i++) {
            var q = rb.pts[i], f = rb.pts[i - 1];
            var dx = f.x - q.x, dy = f.y - q.y;
            q.x += dx * rb.follow; q.y += dy * rb.follow;
            moving += Math.abs(dx) + Math.abs(dy);
          }
          drawRibbon(rb, 0.4 + 0.6 * k);
        }
        ctx.globalAlpha = 1;
        still = moving < 0.5 ? still + 1 : 0;
        if ((on && still < 30) || (!on && k > 0.01)) raf = requestAnimationFrame(frame);
        else if (!on) { k = 0; placed = false; ctx.clearRect(0, 0, w, h); }
      }
      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') hold = Date.now() + 3000;
        else hover = true;
        aim(e.clientX, e.clientY);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        hover = false;
        wake();
      });
      // Touch: the ribbons swoop over to the finger.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 3000;
        aim(e.clientX, e.clientY);
      });
      // Let the ribbons fade out after a touch hold ends.
      setInterval(function () { if (visible && hold && Date.now() > hold) { hold = 0; wake(); } }, 500);

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);
    }

    function init() {
      if (reduced()) return;
      document.querySelectorAll('[data-ribbon-trail]').forEach(function (el) {
        if (el._rbt) return;
        el._rbt = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-ribbon-trail>…</section>

<!-- a single, long gold ribbon: -->
<section data-ribbon-trail data-color="#f59e0b" data-length="40">…</section>