Copied!
Background

Connected vertices canvas

Drifting points joined by lines when they are near each other, with brighter links to the pointer.

← Library

Element · Vanilla JS + CanvasUpdated November 5, 2026

BackgroundElementVanilla JS + Canvas

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Connected vertices canvas" for my site using the Enrichr Library element at https://enrichr.dev/elements/connected-vertices-canvas.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/connected-vertices-canvas.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- density: 1.8
- linkDistance: 110
- speed: 1
- colorA: #e6ff55
- colorB: #fbf8f1

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/connected-vertices-canvas.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-cv"><canvas class="ef-cv__c" role="img" aria-label="Drifting points joined by thin lines when they are close to each other or to the pointer"></canvas></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-cv { position: relative; width: 100%; min-height: 20rem; overflow: hidden; border-radius: 1rem; background: radial-gradient(ellipse at 50% 35%, #12382c, #0c1e18 70%); touch-action: pan-y; cursor: crosshair; }
.ef-cv__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Connected vertices canvas: drifting points joined by lines whenever two are close enough, and by brighter lines to the pointer. A uniform grid (spatial hash) keeps neighbour lookups cheap.
// Reduced motion freezes the points in place (the pointer still lights up nearby links). The loop pauses when the element is off screen.
// mount(root, { density (points per 10,000 square px), linkDistance (px), speed (0-3), colorA (points and links), colorB (pointer links) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-cv");
  var canvas = root.querySelector(".ef-cv__c");
  var ctx = canvas.getContext("2d");
  var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0;
  var W = 1, H = 1, dpr = 1, n = 0, px, py, vx, vy, sz;
  var cols = 1, rows = 1, cell = 100, head = new Int32Array(1), next = new Int32Array(1);
  var mx = -9999, my = -9999, hasPtr = false, ca = [230, 255, 85], cb = [251, 248, 241], seed = 4217;
  var BUCKETS = 5;

  function rnd() { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }
  function rgb(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
  function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }

  function populate() {
    var want = Math.max(24, Math.min(900, Math.round(opts.density * W * H / 10000)));
    if (want === n && px) return;
    var ox = px, oy = py, ovx = vx, ovy = vy, osz = sz, on = n;
    px = new Float32Array(want); py = new Float32Array(want); vx = new Float32Array(want); vy = new Float32Array(want); sz = new Float32Array(want);
    for (var i = 0; i < want; i++) {
      if (i < on) { px[i] = ox[i]; py[i] = oy[i]; vx[i] = ovx[i]; vy[i] = ovy[i]; sz[i] = osz[i]; }
      else {
        var a = rnd() * 6.2832, s = 14 + rnd() * 26;
        px[i] = rnd() * W; py[i] = rnd() * H; vx[i] = Math.cos(a) * s; vy[i] = Math.sin(a) * s; sz[i] = 1 + rnd() * 1.6;
      }
    }
    n = want; next = new Int32Array(n);
  }
  function size() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = Math.max(1, wrap.clientWidth); H = Math.max(1, wrap.clientHeight);
    canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    for (var i = 0; i < n; i++) { if (px[i] > W) px[i] = rnd() * W; if (py[i] > H) py[i] = rnd() * H; }
    populate();
  }
  function grid() {
    cell = Math.max(24, opts.linkDistance);
    cols = Math.max(1, Math.ceil(W / cell)); rows = Math.max(1, Math.ceil(H / cell));
    if (head.length !== cols * rows) head = new Int32Array(cols * rows);
    head.fill(-1);
    for (var i = 0; i < n; i++) {
      var cx = Math.min(cols - 1, Math.max(0, (px[i] / cell) | 0)), cy = Math.min(rows - 1, Math.max(0, (py[i] / cell) | 0));
      var k = cy * cols + cx; next[i] = head[k]; head[k] = i;
    }
  }
  function step(dt) {
    var sp = opts.speed, pr = opts.linkDistance * 1.7, pr2 = pr * pr;
    for (var i = 0; i < n; i++) {
      var x = px[i], y = py[i];
      if (hasPtr) {
        var dx = mx - x, dy = my - y, d2 = dx * dx + dy * dy;
        if (d2 < pr2 && d2 > 1) { var d = Math.sqrt(d2), pull = (1 - d / pr) * 38 * dt; vx[i] += dx / d * pull; vy[i] += dy / d * pull; }
      }
      var s2 = vx[i] * vx[i] + vy[i] * vy[i];
      if (s2 > 3600) { var k = 60 / Math.sqrt(s2); vx[i] *= k; vy[i] *= k; }
      else if (s2 < 100) { vx[i] *= 1.02; vy[i] *= 1.02; }
      x += vx[i] * sp * dt; y += vy[i] * sp * dt;
      if (x < 0) { x = 0; vx[i] = Math.abs(vx[i]); } else if (x > W) { x = W; vx[i] = -Math.abs(vx[i]); }
      if (y < 0) { y = 0; vy[i] = Math.abs(vy[i]); } else if (y > H) { y = H; vy[i] = -Math.abs(vy[i]); }
      px[i] = x; py[i] = y;
    }
  }
  function render() {
    ctx.clearRect(0, 0, W, H);
    grid();
    var L = opts.linkDistance, L2 = L * L, b, i, j, k, cx, cy, ox, oy, nx, ny, dx, dy, d2;
    // bucket link segments by strength so we stroke a handful of paths, not thousands
    var paths = [];
    for (b = 0; b < BUCKETS; b++) paths.push(new Path2D());
    for (i = 0; i < n; i++) {
      cx = Math.min(cols - 1, Math.max(0, (px[i] / cell) | 0)); cy = Math.min(rows - 1, Math.max(0, (py[i] / cell) | 0));
      for (oy = -1; oy <= 1; oy++) {
        ny = cy + oy; if (ny < 0 || ny >= rows) continue;
        for (ox = -1; ox <= 1; ox++) {
          nx = cx + ox; if (nx < 0 || nx >= cols) continue;
          for (j = head[ny * cols + nx]; j !== -1; j = next[j]) {
            if (j <= i) continue;
            dx = px[i] - px[j]; dy = py[i] - py[j]; d2 = dx * dx + dy * dy;
            if (d2 < L2) {
              b = Math.min(BUCKETS - 1, ((1 - Math.sqrt(d2) / L) * BUCKETS) | 0);
              paths[b].moveTo(px[i], py[i]); paths[b].lineTo(px[j], py[j]);
            }
          }
        }
      }
    }
    ctx.lineWidth = 1; ctx.lineCap = "round";
    for (b = 0; b < BUCKETS; b++) { ctx.strokeStyle = rgba(ca, (0.08 + (b + 1) / BUCKETS * 0.5).toFixed(2)); ctx.stroke(paths[b]); }
    if (hasPtr) {
      var pr = L * 1.7, pr2 = pr * pr, pp = new Path2D(), pp2 = new Path2D();
      for (i = 0; i < n; i++) {
        dx = px[i] - mx; dy = py[i] - my; d2 = dx * dx + dy * dy;
        if (d2 < pr2) { (d2 < pr2 * 0.4 ? pp2 : pp).moveTo(mx, my); (d2 < pr2 * 0.4 ? pp2 : pp).lineTo(px[i], py[i]); }
      }
      ctx.strokeStyle = rgba(cb, 0.28); ctx.stroke(pp);
      ctx.strokeStyle = rgba(cb, 0.7); ctx.stroke(pp2);
      ctx.fillStyle = rgba(cb, 0.95); ctx.beginPath(); ctx.arc(mx, my, 3.2, 0, 6.2832); ctx.fill();
      ctx.strokeStyle = rgba(cb, 0.35); ctx.beginPath(); ctx.arc(mx, my, 9, 0, 6.2832); ctx.stroke();
    }
    ctx.fillStyle = rgba(ca, 0.95);
    for (i = 0; i < n; i++) { ctx.beginPath(); ctx.arc(px[i], py[i], sz[i], 0, 6.2832); ctx.fill(); }
  }
  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = Math.min(0.05, (now - last) / 1000 || 0.016); last = now;
    if (!visible || document.hidden) return;
    step(dt); render();
  }
  function onMove(e) {
    var r = wrap.getBoundingClientRect(); mx = e.clientX - r.left; my = e.clientY - r.top; hasPtr = true;
    if (reduce) render();
  }
  function onLeave() { hasPtr = false; if (reduce) render(); }
  function update(partial) {
    opts = Object.assign({ density: 1.8, linkDistance: 110, speed: 1, colorA: "#e6ff55", colorB: "#fbf8f1" }, opts, partial || {});
    ca = rgb(opts.colorA, "e6ff55"); cb = rgb(opts.colorB, "fbf8f1");
    if (px) populate();
    if (reduce && !destroyed && px) render();
  }

  update(o);
  size();
  if (reduce) render();
  wrap.addEventListener("pointermove", onMove);
  wrap.addEventListener("pointerdown", onMove);
  wrap.addEventListener("pointerleave", onLeave);
  if (!reduce) {
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
    last = performance.now(); raf = requestAnimationFrame(loop);
  }
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (destroyed) return; size(); if (reduce) render(); }); ro.observe(wrap); }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf);
      wrap.removeEventListener("pointermove", onMove); wrap.removeEventListener("pointerdown", onMove); wrap.removeEventListener("pointerleave", onLeave);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"density":1.8,"linkDistance":110,"speed":1,"colorA":"#e6ff55","colorB":"#fbf8f1"});

How it works

Points drift with a small velocity and bounce off the edges. To find pairs that are close enough to join, the canvas is split into a grid whose cells are as wide as the link distance, so each point only checks its own cell and the eight around it instead of every other point. Links are collected into five paths by strength and stroked once per path, which keeps the number of draw calls fixed. The pointer joins any point within a larger radius and gently pulls nearby points toward it.

Accessibility and performance

The canvas is decorative and has a text label. Reduced motion freezes the points where they are, and the pointer links still appear on movement without anything drifting. Nothing here needs keyboard input.

The grid makes neighbour search roughly linear in the number of points, and drawing is five line paths plus the dots. Point count is capped at 900 and the loop pauses off screen or in a hidden tab.

SectionBackground
TechniquesPointer interaction
StackVanilla JS, Canvas
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byConnected Vertices - How to by Louis Hoebregts on Codrops. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

How many points can it handle?

Several hundred run smoothly on a laptop. The count scales with area, so a large hero gets more points at the same density.

Can links fade by distance?

They already do. Each link falls into one of five opacity buckets by length, so short links are strongest.

How do I make the pointer repel instead of attract?

Change the sign of the pull in the step function.