← Hover & cursor

Paint Trail

The pointer paints a thick, wet brush stroke behind it, blending through your colours as it goes. Slow strokes come out fat and quick flicks thin, and the paint tapers and fades away a second later.

Paint the town

Move your pointer, or drag a finger

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-paint-trail to a section. The paint is drawn behind its content.

Options

Add any of these attributes to the same element.

data-colorOne colour, or a comma-separated list the paint slowly blends through. Default #a855f7,#ec4899,#f59e0b.
data-sizeBrush width, in px. Default 22.
data-lifeHow long the paint lasts. Default 1100 ms.
data-autoplayA pretend brush paints loops while a real pointer isn’t in the section. Handy for demos and touch screens.

The paint doesn’t catch clicks, so links and buttons in the section still work. On touch screens, drag a finger to paint. With reduced motion turned on, there is no paint.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Paint Trail: the pointer paints a thick brush stroke that tapers and
    // fades away behind it. Fast strokes come out thinner, slow ones fatter,
    // like a loaded brush.
    // Usage: <section data-paint-trail>…</section>
    // Optional: data-color (one colour, or a comma-separated list the paint
    // slowly blends through; default #a855f7,#ec4899,#f59e0b), data-size
    // (brush width, px, default 22), data-life (how long paint lasts, ms,
    // default 1100), data-autoplay (a pretend pointer paints loops while a
    // real one isn't in the section; handy for demos and touch screens).
    // The paint is drawn behind the section's content and doesn't catch clicks.
    var DEFAULTS = { color: '#a855f7,#ec4899,#f59e0b', size: 22, life: 1100 };

    var css =
      '.pnt{position:relative;overflow:hidden;isolation:isolate}' +
      '.pnt-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 hex(c) {
      c = c.trim().replace('#', '');
      if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
      var n = parseInt(c, 16);
      return isNaN(n) ? [168, 85, 247] : [n >> 16 & 255, n >> 8 & 255, n & 255];
    }

    function setup(el) {
      var d = el.dataset;
      var colours = (d.color || DEFAULTS.color).split(',').map(hex);
      var size = num(d.size, DEFAULTS.size);
      var life = Math.max(200, num(d.life, DEFAULTS.life));
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      if (reduced()) return; // no paint with reduced motion
      el.classList.add('pnt');

      var canvas = document.createElement('canvas');
      canvas.className = 'pnt-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');

      var w = 0, h = 0;
      function size_() {
        w = el.clientWidth; h = el.clientHeight;
        var 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);
      }

      // Points of the current stroke, newest last: x, y, time, width, hue.
      var pts = [], hueT = 0, last = null;
      var hover = false, hold = 0, visible = false, raf = 0, t = Math.random() * 6;

      function colourAt(k) {
        if (colours.length === 1) return colours[0];
        var f = (k % colours.length + colours.length) % colours.length;
        var i = Math.floor(f), m = f - i;
        var a = colours[i], b = colours[(i + 1) % colours.length];
        return [a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, a[2] + (b[2] - a[2]) * m];
      }

      function add(x, y) {
        var now = performance.now();
        if (last) {
          var dx = x - last.x, dy = y - last.y, dist = Math.sqrt(dx * dx + dy * dy);
          if (dist < 2) return;
          // Break the stroke after a pause or a jump.
          if (now - last.t > 300 || dist > Math.max(w, h) * 0.5) pts.push(null);
          // Faster moves lay down a thinner line.
          var speed = dist / Math.max(8, now - last.t);
          var target = size * Math.max(0.35, Math.min(1.15, 1.25 - speed * 0.35));
          var width = last.wd + (target - last.wd) * 0.3;
          // Fill in long gaps so the stroke stays smooth.
          var steps = Math.min(12, Math.floor(dist / 10));
          for (var s = 1; s <= steps; s++) {
            var f = s / (steps + 1);
            pts.push({ x: last.x + dx * f, y: last.y + dy * f, t: last.t + (now - last.t) * f, wd: last.wd + (width - last.wd) * f, c: hueT });
          }
          hueT += dist * 0.004;
          last = { x: x, y: y, t: now, wd: width, c: hueT };
        } else {
          last = { x: x, y: y, t: now, wd: size * 0.6, c: hueT };
        }
        pts.push(last);
        wake();
      }

      function draw(now) {
        ctx.clearRect(0, 0, w, h);
        // Drop paint that has dried away.
        while (pts.length && (pts[0] === null || now - pts[0].t > life)) pts.shift();
        var run = [];
        for (var i = 0; i <= pts.length; i++) {
          var p = pts[i];
          if (p) { run.push(p); continue; }
          if (run.length > 1) stroke(run, now);
          run = [];
        }
      }

      // Draw one stroke as a filled ribbon whose width shrinks with age.
      function stroke(run, now) {
        var left = [], right = [], n = run.length;
        for (var i = 0; i < n; i++) {
          var p = run[i], a = run[Math.max(0, i - 1)], b = run[Math.min(n - 1, i + 1)];
          var tx = b.x - a.x, ty = b.y - a.y, tl = Math.sqrt(tx * tx + ty * ty) || 1;
          var age = Math.min(1, (now - p.t) / life);
          var r = p.wd / 2 * (1 - age * age) * (i === n - 1 ? 0.9 : 1);
          left.push([p.x - ty / tl * r, p.y + tx / tl * r]);
          right.push([p.x + ty / tl * r, p.y - tx / tl * r]);
        }
        var head = run[n - 1], tail = run[0];
        var g = ctx.createLinearGradient(tail.x, tail.y, head.x, head.y);
        var ca = colourAt(tail.c), cb = colourAt(head.c);
        var ageTail = Math.min(1, (now - tail.t) / life);
        g.addColorStop(0, 'rgba(' + (ca[0] | 0) + ',' + (ca[1] | 0) + ',' + (ca[2] | 0) + ',' + (0.85 * (1 - ageTail)).toFixed(3) + ')');
        g.addColorStop(1, 'rgba(' + (cb[0] | 0) + ',' + (cb[1] | 0) + ',' + (cb[2] | 0) + ',0.92)');
        ctx.fillStyle = g;
        ctx.beginPath();
        path(left, true);
        // Round cap at the head.
        var hr = head.wd / 2 * 0.9;
        ctx.arc(head.x, head.y, Math.max(0.1, hr), Math.atan2(left[n - 1][1] - head.y, left[n - 1][0] - head.x), Math.atan2(right[n - 1][1] - head.y, right[n - 1][0] - head.x), true);
        path(right.reverse(), false);
        ctx.closePath();
        ctx.fill();
        // A thin glossy streak down the middle, like wet paint.
        ctx.strokeStyle = 'rgba(255,255,255,.2)';
        ctx.lineCap = 'round';
        ctx.lineWidth = Math.max(1, size * 0.1);
        ctx.beginPath();
        var from = Math.floor(n * 0.45);
        for (var j = from; j < n; j++) {
          if (j === from) ctx.moveTo(run[j].x, run[j].y);
          else ctx.lineTo(run[j].x, run[j].y);
        }
        ctx.stroke();
      }
      // Smooth curve through points, via midpoints.
      function path(p, first) {
        if (first) ctx.moveTo(p[0][0], p[0][1]);
        else ctx.lineTo(p[0][0], p[0][1]);
        for (var i = 1; i < p.length - 1; i++) {
          var mx = (p[i][0] + p[i + 1][0]) / 2, my = (p[i][1] + p[i + 1][1]) / 2;
          ctx.quadraticCurveTo(p[i][0], p[i][1], mx, my);
        }
        ctx.lineTo(p[p.length - 1][0], p[p.length - 1][1]);
      }

      function frame() {
        raf = 0;
        if (!visible) return;
        var now = performance.now();
        var fake = auto && !hover && Date.now() > hold;
        if (fake) {
          // A looping, figure-of-eight brush stroke.
          t += 0.022;
          add(w * (0.5 + 0.36 * Math.sin(t)), h * (0.5 + 0.3 * Math.sin(t * 2) * Math.cos(t * 0.37)));
        }
        draw(now);
        if (!raf && (fake || pts.length)) raf = requestAnimationFrame(frame);
      }
      function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }

      function at(e) {
        var b = el.getBoundingClientRect();
        if (Date.now() <= hold || hover) add(e.clientX - b.left, e.clientY - b.top);
      }
      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        hover = true; last = null; pts.push(null);
      });
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') hold = Date.now() + 2000;
        else hover = true;
        at(e);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        hover = false; last = null; pts.push(null);
      });
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 2000;
        last = null; pts.push(null);
        at(e);
      });

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

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

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

<!-- a fat blue brush that lingers: -->
<section data-paint-trail data-color="#0ea5e9,#6366f1" data-size="36" data-life="2000">…</section>