Connector Beam
A glowing beam of light travels along a curved path from one icon to another, trailing a comet tail, and the icon it reaches gives a little pulse. Point one icon at several and the beams take turns: just right for “connects to” and integration diagrams.
How to use it
- Paste the snippet just before
</head>. - Add
data-connector-beamto a container, and lay out your icons inside it however you like (flex, grid). - Mark the icon the beams start from with
data-beam-from, and each one they travel to withdata-beam-to.
Options
Add any of these attributes to the same element.
data-color | Colour of the beam. Default #8b5cf6. |
data-color2 | Colour at the beam’s head. Default #ec4899. |
data-curve | How much the path bends, as a share of its length; negative bends the other way. Default 0.2. Each target can set its own. |
data-duration | Time for each trip. Default 1800 ms. |
data-stagger | Gap between the beams setting off. Default 450 ms. |
data-reverse | Run the beams from the targets to the source. |
The paths follow the icons when the layout changes size. The beams are hidden from screen readers, so label the icons. With reduced motion the paths simply show, lit.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Connector Beam: a glowing beam of light travels along a curved path from
// one icon to another, fading in a comet tail behind it; the icon it
// reaches gives a little pulse. Point one icon at several and the beams
// take turns. Good for "connects to" and integration diagrams.
// Usage:
// <div data-connector-beam>
// <span data-beam-from>…icon…</span>
// <span data-beam-to>…icon…</span>
// </div>
// Lay the icons out however you like (flex, grid); the beams find them.
// Optional: data-color (beam colour, default #8b5cf6), data-color2 (colour
// at the head, default #ec4899), data-curve (how much the path bends, as a
// share of its length, negative bends the other way, default 0.2; a target
// can set its own data-curve), data-duration (ms per trip, default 1800),
// data-stagger (ms between beams, default 450), data-reverse (beams run
// from the targets to the source).
var DEFAULTS = { color: '#8b5cf6', color2: '#ec4899', curve: 0.2, duration: 1800, stagger: 450, rest: 700 };
var NS = 'http://www.w3.org/2000/svg';
var uid = 0;
var css =
'.cnb{position:relative}' +
'.cnb-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;z-index:0}' +
'.cnb [data-beam-from],.cnb [data-beam-to]{position:relative;z-index:1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function make(tag, attrs, parent) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function setup(el) {
var d = el.dataset;
var from = el.querySelector('[data-beam-from]');
var tos = Array.prototype.slice.call(el.querySelectorAll('[data-beam-to]'));
if (!from || !tos.length) return null;
var id = 'cnb-' + ++uid;
var color = d.color || DEFAULTS.color, color2 = d.color2 || DEFAULTS.color2;
el.classList.add('cnb');
var svg = make('svg', { class: 'cnb-svg', 'aria-hidden': 'true', fill: 'none', 'stroke-linecap': 'round' });
el.insertBefore(svg, el.firstChild);
var defs = make('defs', {}, svg);
var glow = make('filter', { id: id + '-glow', x: '-50%', y: '-50%', width: '200%', height: '200%' }, defs);
make('feGaussianBlur', { stdDeviation: 3 }, glow);
var beams = tos.map(function (to, i) {
var gid = id + '-g' + i;
var grad = make('linearGradient', { id: gid, gradientUnits: 'userSpaceOnUse' }, defs);
make('stop', { offset: 0, 'stop-color': color, 'stop-opacity': 0 }, grad);
make('stop', { offset: 0.7, 'stop-color': color }, grad);
make('stop', { offset: 1, 'stop-color': color2 }, grad);
var track = make('path', { stroke: color, 'stroke-opacity': 0.16, 'stroke-width': 2 }, svg);
var halo = make('path', { stroke: 'url(#' + gid + ')', 'stroke-width': 6, filter: 'url(#' + id + '-glow)' }, svg);
var core = make('path', { stroke: 'url(#' + gid + ')', 'stroke-width': 2.5 }, svg);
return { to: to, grad: grad, track: track, halo: halo, core: core, curve: to.dataset.curve, L: 0 };
});
return { el: el, from: from, beams: beams, curve: d.curve !== undefined ? +d.curve : DEFAULTS.curve,
reverse: d.reverse !== undefined && d.reverse !== 'false' };
}
function centre(node, box) {
var r = node.getBoundingClientRect();
return { x: r.left + r.width / 2 - box.left, y: r.top + r.height / 2 - box.top, r: Math.min(r.width, r.height) / 2 };
}
// Lay each path from the source's centre to the target's, bowed sideways.
function layout(o) {
var box = o.el.getBoundingClientRect();
var a = centre(o.from, box);
o.beams.forEach(function (b) {
var c = centre(b.to, box);
var p = o.reverse ? c : a, q = o.reverse ? a : c;
var dx = q.x - p.x, dy = q.y - p.y;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var bend = (b.curve !== undefined ? +b.curve : o.curve) * len;
var nx = -dy / len, ny = dx / len;
var d = 'M' + p.x.toFixed(1) + ',' + p.y.toFixed(1) +
' C' + (p.x + dx / 3 + nx * bend).toFixed(1) + ',' + (p.y + dy / 3 + ny * bend).toFixed(1) +
' ' + (p.x + (dx * 2) / 3 + nx * bend).toFixed(1) + ',' + (p.y + (dy * 2) / 3 + ny * bend).toFixed(1) +
' ' + q.x.toFixed(1) + ',' + q.y.toFixed(1);
[b.track, b.halo, b.core].forEach(function (path) { path.setAttribute('d', d); });
b.L = b.core.getTotalLength();
// Start and end at the icons' edges, not their centres.
b.start = Math.min(b.L * 0.4, (o.reverse ? c : a).r);
b.end = Math.max(b.L * 0.6, b.L - (o.reverse ? a : c).r);
b.seg = Math.max(40, Math.min(b.L * 0.38, 160));
b.target = o.reverse ? o.from : b.to;
});
}
// Draw the beam with its head at distance h along the path.
function place(b, h) {
var tail = h - b.seg;
var lo = Math.max(b.start, tail), hi = Math.min(b.end, h);
var on = hi - lo;
[b.halo, b.core].forEach(function (path) {
path.style.strokeDasharray = Math.max(0, on) + ' ' + (b.L * 2);
path.style.strokeDashoffset = -lo;
path.style.opacity = on > 1 ? 1 : 0;
});
if (on <= 1) return;
var p0 = b.core.getPointAtLength(Math.max(0, tail)), p1 = b.core.getPointAtLength(Math.min(b.L, h));
b.grad.setAttribute('x1', p0.x); b.grad.setAttribute('y1', p0.y);
b.grad.setAttribute('x2', p1.x); b.grad.setAttribute('y2', p1.y);
}
function pulse(node) {
anime.animate(node, { scale: [{ to: 1.12, duration: 160, ease: 'out(2)' }, { to: 1, duration: 520, ease: 'outElastic(1, .5)' }] });
}
function start(o) {
var d = o.el.dataset;
var dur = +d.duration || DEFAULTS.duration;
var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
var cycle = Math.max(dur + DEFAULTS.rest, o.beams.length * stagger + DEFAULTS.rest);
var visible = false, raf = 0, clock = 0, last = 0;
o.beams.forEach(function (b, i) { b.offset = i * stagger; b.lap = -1; });
var frame = function (now) {
raf = 0;
if (!visible) return;
clock += Math.min(50, now - (last || now));
last = now;
o.beams.forEach(function (b) {
var local = clock - b.offset;
if (local < 0) { place(b, -1); return; }
var lap = Math.floor(local / cycle), t = Math.min(1, (local % cycle) / dur);
var e = t * t * (3 - 2 * t); // smoothstep: eases away and eases in
place(b, b.start + e * (b.end - b.start + b.seg));
// Pulse the icon as the head arrives.
if (t > 0.6 && b.lap !== lap) { b.lap = lap; pulse(b.target); }
});
raf = requestAnimationFrame(frame);
};
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
last = 0;
if (visible && !raf) raf = requestAnimationFrame(frame);
}, { threshold: 0.1 }).observe(o.el);
}
function lit(o) {
o.beams.forEach(function (b) {
b.core.style.strokeDasharray = 'none';
b.halo.style.opacity = 0;
b.grad.setAttribute('x1', b.core.getPointAtLength(0).x); b.grad.setAttribute('y1', b.core.getPointAtLength(0).y);
b.grad.setAttribute('x2', b.core.getPointAtLength(b.L).x); b.grad.setAttribute('y2', b.core.getPointAtLength(b.L).y);
});
}
function init() {
document.querySelectorAll('[data-connector-beam]').forEach(function (el) {
if (el._cnb) return;
var o = setup(el);
el._cnb = o || true;
if (!o) return;
layout(o);
var still = reduced();
new ResizeObserver(function () { layout(o); if (still) lit(o); }).observe(el);
if (still) lit(o);
else start(o);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-connector-beam style="display: flex; justify-content: space-between">
<img data-beam-from src="/app.svg" alt="Your app" width="56">
<img data-beam-to src="/slack.svg" alt="Slack" width="48">
</div>