← Hover & cursor

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

  1. Paste the snippet just before </head>.
  2. Add data-magnet-lines to 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-gapSpace between lines, in px. Default 34.
data-lengthLength of each line, in px. Default 16.
data-widthThickness of each line, in px. Default 3.
data-colorLine colour. Default #6a0dad.
data-angleThe angle lines rest at, in degrees. Default -45.
data-autoplayA 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.

1 · Snippet
<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>
2 · Your HTML
<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>