← SVG & icons

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

  1. Paste the snippet just before </head>.
  2. Add data-connector-beam to a container, and lay out your icons inside it however you like (flex, grid).
  3. Mark the icon the beams start from with data-beam-from, and each one they travel to with data-beam-to.

Options

Add any of these attributes to the same element.

data-colorColour of the beam. Default #8b5cf6.
data-color2Colour at the beam’s head. Default #ec4899.
data-curveHow 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-durationTime for each trip. Default 1800 ms.
data-staggerGap between the beams setting off. Default 450 ms.
data-reverseRun 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.

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