Constellation Network
Dots drift slowly behind a section and link up with fine lines whenever two come close, the lines growing stronger as they near and fading as they part. Move the pointer, or touch, and it joins the network, drawing nearby dots towards it.
Stay connected
How to use it
- Paste the snippet just before
</head>. - Add
data-constellation-networkto a section with a dark background.
Options
Add any of these attributes to the same element.
data-color | Colour of the dots and lines. Default lavender #c4b5fd. |
data-density | How many dots, as a multiplier. Default 1. |
data-distance | How close two dots must be to link, in px. Default about a third of the section’s shorter side. |
data-speed | Speed multiplier. Default 1. |
data-interactive="false" | Ignore the pointer. |
The number of dots follows the section’s size, so a phone gets as busy a network as a wide screen. It’s drawn on a transparent canvas behind your content and paused when off screen; clicks still reach your links and buttons. With reduced motion the dots hold still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Constellation Network: dots drift slowly behind a section and link up
// with fine lines whenever two come close, the lines fading in and out as
// they near and part. The pointer (or a finger) joins in too, gently
// pulling nearby dots towards it. Drawn on a transparent canvas behind
// your content, and paused when off screen.
// Usage: <section data-constellation-network style="background:#0b0a1a">…</section>
// Optional: data-color (dots and lines, default lavender #c4b5fd),
// data-density (how many dots, as a multiplier, default 1), data-distance
// (how close two dots must be to link, in px, default about a third of the
// section's shorter side), data-speed (default 1), data-interactive="false"
// (ignore the pointer).
var DEFAULTS = { color: '#c4b5fd', density: 1, speed: 1 };
var css =
'[data-constellation-network]{position:relative;isolation:isolate;overflow:hidden}' +
'.cnw-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);
var TAU = Math.PI * 2;
var probe = document.createElement('canvas').getContext('2d');
function rgb(c) {
probe.fillStyle = '#000';
probe.fillStyle = c;
var v = probe.fillStyle;
if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
return v.match(/[\d.]+/g).slice(0, 3).map(Number);
}
function build(el) {
var d = el.dataset;
var col = rgb(d.color || DEFAULTS.color).join(',');
var density = d.density !== undefined ? +d.density : DEFAULTS.density;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var interactive = d.interactive !== 'false' && !el.closest('[aria-hidden="true"]');
var canvas = document.createElement('canvas');
canvas.className = 'cnw-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1, link = 100, dots = [], pointer = null;
var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
var ow = w, oh = h;
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
link = +d.distance || Math.max(70, Math.min(150, Math.min(w, h) * 0.36));
// Keep about the same number of links per dot at any size.
var n = Math.round(Math.max(16, Math.min(150, ((w * h) / (link * link)) * 4.5 * density)));
// Stretch existing dots to the new size, and add or drop the rest.
dots.forEach(function (p) { if (ow) { p.x *= w / ow; p.y *= h / oh; } });
while (dots.length < n) {
var a = R(0, TAU, 3), v = R(8, 22, 1);
dots.push({ x: R(0, w, 1), y: R(0, h, 1), vx: Math.cos(a) * v, vy: Math.sin(a) * v, r: R(1.2, 2.4, 2) });
}
dots.length = n;
draw();
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.lineWidth = 1;
var L2 = link * link;
for (var i = 0; i < dots.length; i++) {
var p = dots[i];
for (var j = i + 1; j < dots.length; j++) {
var q = dots[j], dx = p.x - q.x, dy = p.y - q.y, d2 = dx * dx + dy * dy;
if (d2 > L2) continue;
var a = 1 - Math.sqrt(d2) / link;
ctx.strokeStyle = 'rgba(' + col + ',' + (a * 0.55).toFixed(3) + ')';
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.stroke();
}
}
if (pointer) {
var reach = link * 1.35;
for (var k = 0; k < dots.length; k++) {
var o = dots[k], ex = o.x - pointer.x, ey = o.y - pointer.y, dist = Math.sqrt(ex * ex + ey * ey);
if (dist > reach) continue;
ctx.strokeStyle = 'rgba(' + col + ',' + ((1 - dist / reach) * 0.8).toFixed(3) + ')';
ctx.beginPath();
ctx.moveTo(pointer.x, pointer.y);
ctx.lineTo(o.x, o.y);
ctx.stroke();
}
}
ctx.fillStyle = 'rgb(' + col + ')';
for (var m = 0; m < dots.length; m++) {
var s = dots[m];
ctx.globalAlpha = 0.9;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, TAU);
ctx.fill();
ctx.globalAlpha = 0.15;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r * 3, 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function advance(dt) {
var s = (dt / 1000) * speed;
for (var i = 0; i < dots.length; i++) {
var p = dots[i];
// Near the pointer, dots are drawn gently towards it.
if (pointer) {
var dx = pointer.x - p.x, dy = pointer.y - p.y, dist = Math.sqrt(dx * dx + dy * dy);
if (dist < link * 1.35 && dist > 20) {
p.x += (dx / dist) * 18 * s;
p.y += (dy / dist) * 18 * s;
}
}
p.x += p.vx * s;
p.y += p.vy * s;
// Bounce softly off the edges.
if (p.x < 0) { p.x = 0; p.vx = Math.abs(p.vx); }
if (p.x > w) { p.x = w; p.vx = -Math.abs(p.vx); }
if (p.y < 0) { p.y = 0; p.vy = Math.abs(p.vy); }
if (p.y > h) { p.y = h; p.vy = -Math.abs(p.vy); }
}
}
if (interactive && !still) {
var release = 0;
var at = function (e) {
var b = el.getBoundingClientRect();
pointer = { x: e.clientX - b.left, y: e.clientY - b.top };
clearTimeout(release);
};
el.addEventListener('pointermove', at);
el.addEventListener('pointerdown', at);
// A finger lifts away; keep its links for a moment so taps show.
var leave = function (e) {
clearTimeout(release);
release = setTimeout(function () { pointer = null; }, e.pointerType === 'mouse' ? 0 : 900);
};
el.addEventListener('pointerleave', leave);
el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') leave(e); });
el.addEventListener('pointercancel', leave);
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (still) return;
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
advance(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-constellation-network]').forEach(function (el) {
if (el._cnw) return;
el._cnw = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-constellation-network style="background: #0b0a1a; color: #fff">
<h1>Stay connected</h1>
</section>