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 fingerHow to use it
- Paste the snippet just before
</head>. - Add
data-paint-trailto a section. The paint is drawn behind its content.
Options
Add any of these attributes to the same element.
data-color | One colour, or a comma-separated list the paint slowly blends through. Default #a855f7,#ec4899,#f59e0b. |
data-size | Brush width, in px. Default 22. |
data-life | How long the paint lasts. Default 1100 ms. |
data-autoplay | A 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.
<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><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>