Magnet Lines
A field of short lines, like iron filings round a magnet, all turn to point at the pointer as it moves. Lines near it grow a little longer and darker, and when it leaves they swing back to rest.
How to use it
- Paste the snippet just before
</head>. - Add
data-magnet-linesto an element with a size. It fills itself with lines, drawn behind any content.
Options
Add any of these attributes to the same element.
data-gap | Space between lines, in px. Default 34. |
data-length | Length of each line, in px. Default 16. |
data-width | Thickness of each line, in px. Default 3. |
data-color | Line colour. Default #6a0dad. |
data-angle | The angle lines rest at, in degrees. Default -45. |
data-autoplay | A pretend pointer circles about while a real one isn’t over it. Handy for demos and touch screens. |
The lines are laid out to fill the element, and re-laid when it changes size. On touch screens they follow your finger. With reduced motion turned on, they turn straight to the pointer without swinging.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Magnet Lines: a field of short lines, like iron filings, that all turn
// to point at the pointer as it moves. Lines near the pointer grow a
// little longer and brighter; when it leaves they ease back to rest.
// Usage: <div data-magnet-lines></div> (give it a size; it fills itself)
// Optional: data-gap (space between lines, px, default 34), data-length
// (line length, px, default 16), data-width (line thickness, px, default
// 3), data-color (default #6a0dad), data-angle (resting angle, degrees,
// default -45), data-autoplay (a pretend pointer circles about while a
// real one isn't over it; handy for demos and touch screens).
// The lines are drawn behind the element's content and don't catch clicks.
var DEFAULTS = { gap: 34, length: 16, width: 3, color: '#6a0dad', angle: -45 };
var css =
'.mgn{position:relative;overflow:hidden;isolation:isolate}' +
'.mgn-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 gap = Math.max(12, num(d.gap, DEFAULTS.gap));
var len = num(d.length, DEFAULTS.length);
var lw = num(d.width, DEFAULTS.width);
var colour = d.color || DEFAULTS.color;
var restAngle = num(d.angle, DEFAULTS.angle) * Math.PI / 180;
var auto = d.autoplay !== undefined && d.autoplay !== 'false';
var still = reduced();
el.classList.add('mgn');
var canvas = document.createElement('canvas');
canvas.className = 'mgn-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var w = 0, h = 0, lines = [];
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);
// Lay the lines out on a grid centred in the element.
var cols = Math.max(1, Math.floor(w / gap)), rows = Math.max(1, Math.floor(h / gap));
var sx = (w - (cols - 1) * gap) / 2, sy = (h - (rows - 1) * gap) / 2;
var old = lines;
lines = [];
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
var prevLine = old[lines.length];
lines.push({ x: sx + c * gap, y: sy + r * gap, a: prevLine ? prevLine.a : restAngle, s: prevLine ? prevLine.s : 0 });
}
}
draw();
}
var pinned = false, pt = { x: 0, y: 0 }, on = 0, hover = false, hold = 0, visible = false, raf = 0, t = Math.random() * 6;
function draw() {
ctx.clearRect(0, 0, w, h);
ctx.lineCap = 'round';
ctx.strokeStyle = colour;
for (var i = 0; i < lines.length; i++) {
var l = lines[i];
var half = len / 2 * (1 + l.s * 0.5);
var ca = Math.cos(l.a) * half, sa = Math.sin(l.a) * half;
ctx.globalAlpha = 0.45 + 0.55 * l.s;
ctx.lineWidth = lw;
ctx.beginPath();
ctx.moveTo(l.x - ca, l.y - sa);
ctx.lineTo(l.x + ca, l.y + sa);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// Turn by the shortest way round.
function turn(from, to, k) {
var diff = Math.atan2(Math.sin(to - from), Math.cos(to - from));
return from + diff * k;
}
function frame() {
raf = 0;
if (!visible) return;
var touchHeld = Date.now() < hold;
var fake = auto && !hover && !touchHeld;
if (fake) {
// A slow loop that drifts around the field.
t += 0.012;
pt.x = w * (0.5 + 0.34 * Math.cos(t) + 0.08 * Math.sin(t * 2.7));
pt.y = h * (0.5 + 0.32 * Math.sin(t * 1.3));
}
var active = hover || touchHeld || fake;
on += ((active ? 1 : 0) - on) * 0.08;
var reach = Math.max(w, h) * 0.45, moving = 0;
for (var i = 0; i < lines.length; i++) {
var l = lines[i];
var dx = pt.x - l.x, dy = pt.y - l.y;
var target = active ? Math.atan2(dy, dx) : pinned ? l.a : restAngle;
var near = active ? Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy) / reach) : 0;
var na = still ? target : turn(l.a, target, 0.14);
var ns = still ? near : l.s + (near - l.s) * 0.12;
moving += Math.abs(na - l.a) + Math.abs(ns - l.s);
l.a = na; l.s = ns;
}
draw();
if (fake || moving > 0.002 || (active && !still)) raf = requestAnimationFrame(frame);
}
function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
function aim(e) {
var b = el.getBoundingClientRect();
pt.x = e.clientX - b.left; pt.y = e.clientY - b.top;
wake();
}
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
hover = true;
aim(e);
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') hold = Date.now() + 2500;
else hover = true;
aim(e);
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
hover = false;
wake();
});
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
hold = Date.now() + 2500;
aim(e);
});
size();
if (still && auto) {
// Reduced motion: a still frame of the lines turned towards one spot.
auto = false;
pinned = true;
lines.forEach(function (l) { l.a = Math.atan2(h * 0.3 - l.y, w * 0.7 - l.x); });
draw();
}
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-magnet-lines]').forEach(function (el) {
if (el._mgn) return;
el._mgn = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="field" data-magnet-lines style="height: 400px"></div>
<!-- denser and thinner: -->
<div data-magnet-lines data-gap="22" data-length="12" data-width="2"></div>