Cursor Grid Trail
The section is laid out as a faint grid, and the cells the pointer passes over light up, then cool and fade away behind it, leaving a glowing trail of squares.
Leave a trail
How to use it
- Paste the snippet just before
</head>. - Add
data-cursor-grid-trailto the section. The grid is drawn behind its content.
Options
Add any of these attributes to the same element.
data-cell | Size of each cell, in px. Default 32. |
data-color | Colour of a freshly lit cell. Default #a855f7. |
data-fade-to | Colour cells cool to as they fade. Default #38bdf8. |
data-life | How long a cell stays lit. Default 900 ms. |
data-lines | Colour of the grid lines. Default a faint white, for dark sections; none to hide them. |
data-autoplay | A pretend pointer wanders over the grid while a real one isn’t in the section. Handy for demos and touch screens. |
It looks best on a dark section; on a light one, set data-lines to something like rgba(0,0,0,.06). Fast moves still light every cell along the way. On touch screens, drag a finger across it. With reduced motion turned on, only the grid shows.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Cursor Grid Trail: the area is laid out as a faint grid, and the cells
// the pointer passes over light up, then fade away behind it, leaving a
// glowing trail of squares.
// Usage: <section data-cursor-grid-trail>…</section>
// Optional: data-cell (cell size, px, default 32), data-color (lit cell
// colour, default #a855f7), data-fade-to (colour cells cool to as they
// fade, default #38bdf8), data-life (how long a cell stays lit, ms,
// default 900), data-lines (colour of the grid lines, default a faint
// white; "none" to hide them), data-autoplay (a pretend pointer wanders
// across the grid while a real one isn't in the area; handy for demos and
// touch screens).
// The grid is drawn behind the area's content and doesn't catch clicks.
var DEFAULTS = { cell: 32, color: '#a855f7', fadeTo: '#38bdf8', life: 900, lines: 'rgba(255,255,255,.07)' };
var css =
'.cgr{position:relative;overflow:hidden;isolation:isolate}' +
'.cgr-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; }
var probe = document.createElement('canvas').getContext('2d');
function rgb(c) {
probe.fillStyle = '#000';
probe.fillStyle = c;
var s = probe.fillStyle;
if (s.charAt(0) === '#') return [parseInt(s.substr(1, 2), 16), parseInt(s.substr(3, 2), 16), parseInt(s.substr(5, 2), 16)];
var m = s.match(/[\d.]+/g) || [0, 0, 0];
return [+m[0], +m[1], +m[2]];
}
function setup(el) {
var d = el.dataset;
var cell = Math.max(8, num(d.cell, DEFAULTS.cell));
var hot = rgb(d.color || DEFAULTS.color), cool = rgb(d.fadeTo || DEFAULTS.fadeTo);
var life = Math.max(100, num(d.life, DEFAULTS.life));
var lines = d.lines || DEFAULTS.lines;
var auto = d.autoplay !== undefined && d.autoplay !== 'false';
var still = reduced();
el.classList.add('cgr');
var canvas = document.createElement('canvas');
canvas.className = 'cgr-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var w = 0, h = 0, cols = 0, rows = 0, ox = 0, oy = 0, lit = new Float32Array(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);
// Centre the grid, so the part-cells at the edges match.
cols = Math.ceil(w / cell) + 1; rows = Math.ceil(h / cell) + 1;
ox = (w - (cols - 1) * cell) / 2 - cell / 2; oy = (h - (rows - 1) * cell) / 2 - cell / 2;
lit = new Float32Array(cols * rows);
draw();
}
function light(x, y) {
var c = Math.floor((x - ox) / cell), r = Math.floor((y - oy) / cell);
if (c < 0 || r < 0 || c >= cols || r >= rows) return;
lit[c + r * cols] = 1;
}
// Light every cell along the way, so fast moves leave an unbroken trail.
var last = null;
function trace(x, y) {
if (last) {
var dx = x - last.x, dy = y - last.y;
var n = Math.max(1, Math.ceil(Math.sqrt(dx * dx + dy * dy) / (cell / 3)));
for (var i = 1; i <= n; i++) light(last.x + dx * i / n, last.y + dy * i / n);
} else light(x, y);
last = { x: x, y: y };
}
function draw() {
ctx.clearRect(0, 0, w, h);
if (lines !== 'none') {
ctx.strokeStyle = lines;
ctx.lineWidth = 1;
ctx.beginPath();
for (var c = 0; c <= cols; c++) { var x = Math.round(ox + c * cell) + 0.5; ctx.moveTo(x, 0); ctx.lineTo(x, h); }
for (var r = 0; r <= rows; r++) { var y = Math.round(oy + r * cell) + 0.5; ctx.moveTo(0, y); ctx.lineTo(w, y); }
ctx.stroke();
}
var any = false, gap = Math.max(1, cell * 0.06), rad = Math.min(6, cell * 0.16);
for (var i = 0; i < lit.length; i++) {
var v = lit[i];
if (v <= 0.01) continue;
any = true;
var cx = ox + (i % cols) * cell, cy = oy + Math.floor(i / cols) * cell;
// Fresh cells are bright and hot; older ones cool and fade.
var k = 1 - v;
var col = 'rgba(' + Math.round(hot[0] + (cool[0] - hot[0]) * k) + ',' + Math.round(hot[1] + (cool[1] - hot[1]) * k) + ',' +
Math.round(hot[2] + (cool[2] - hot[2]) * k) + ',' + (Math.pow(v, 1.4) * 0.9).toFixed(3) + ')';
ctx.fillStyle = col;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(cx + gap, cy + gap, cell - gap * 2, cell - gap * 2, rad);
else ctx.rect(cx + gap, cy + gap, cell - gap * 2, cell - gap * 2);
ctx.fill();
}
return any;
}
var hover = false, hold = 0, visible = false, raf = 0, prev = 0, t = Math.random() * 10;
function frame(now) {
raf = 0;
if (!visible) return;
var dt = prev ? Math.min(64, now - prev) : 16;
prev = now;
var fake = auto && !hover && Date.now() > hold;
if (fake) {
// A wandering path that swoops across the grid.
t += dt * 0.0011;
trace(w * (0.5 + 0.42 * Math.sin(t * 1.3) * Math.cos(t * 0.37)), h * (0.5 + 0.4 * Math.sin(t * 2.1 + 1)));
}
var step = dt / life;
for (var i = 0; i < lit.length; i++) if (lit[i] > 0) lit[i] = Math.max(0, lit[i] - step);
var any = draw();
if (fake || any) raf = requestAnimationFrame(frame);
else prev = 0;
}
function wake() { if (!raf && visible && !still) raf = requestAnimationFrame(frame); }
function local(e) {
var b = el.getBoundingClientRect();
return { x: e.clientX - b.left, y: e.clientY - b.top };
}
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
hover = true; last = null;
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') hold = Date.now() + 2500;
else hover = true;
if (still) return;
var p = local(e);
trace(p.x, p.y);
wake();
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType !== 'touch') { hover = false; last = null; }
});
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
hold = Date.now() + 2500; last = null;
if (still) return;
var p = local(e);
trace(p.x, p.y);
wake();
});
size();
new ResizeObserver(function () { size(); wake(); }).observe(el);
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) wake();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-cursor-grid-trail]').forEach(function (el) {
if (el._cgr) return;
el._cgr = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section class="hero" data-cursor-grid-trail>…</section>
<!-- bigger, warmer cells that linger: -->
<section data-cursor-grid-trail data-cell="48" data-color="#f97316" data-fade-to="#e11d48" data-life="1500">…</section>