Hub and Spoke
A hub reaches out to the icons around it: spokes shoot out one by one with an icon popping at the end of each, then little pulses keep travelling along them and each icon bumps as one lands. Point at or tap an icon to send it a pulse.
API
๐ฑ
๐ป
โ๏ธ
๐๏ธ
โ๏ธ
๐ณ
How to use it
- Paste the snippet just before
</head>. - Add
data-hub-and-spoketo a container. Put the hub inside it withdata-hub, then one child per icon (an emoji, an<img>or an inline SVG). - Add
data-labelto an icon to caption it.
Options
Add any of these attributes to the same element.
data-color | Colour of the hub, spokes and pulses. Default #a855f7. |
data-size | Largest width of the diagram. Default 320 px. |
data-direction | Which way pulses travel: out, in or both. Default both. |
data-interval | Average time between pulses. Default 650 ms. |
data-label | On an icon: a caption under it. |
The circles are sized in em, so set font-size on the container to scale them.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Hub and Spoke: a hub in the middle reaches out to the icons around it.
// The hub pops in, spokes shoot out one by one with an icon popping at the
// end of each, then little pulses keep travelling along the spokes, and
// each icon bumps as a pulse lands. Point at (or tap) an icon to send it a
// pulse straight away.
// Usage:
// <div data-hub-and-spoke>
// <span data-hub>API</span>
// <span data-label="Phone">๐ฑ</span>
// <span data-label="Laptop">๐ป</span>
// <span>โ๏ธ</span> โฆ
// </div>
// The hub is the child with data-hub (or the first child); the rest sit
// round it, starting at the top. data-label adds a caption under an icon.
// Size it with font-size (the circles are in em) and data-size.
// Optional: data-color (default #a855f7), data-size (largest width in px,
// default 320), data-direction ("out" from the hub, "in" to it, or "both";
// default "both"), data-interval (ms between pulses, default 650).
var DEFAULTS = { color: '#a855f7', size: 320, direction: 'both', interval: 650 };
var NS = 'http://www.w3.org/2000/svg';
var css =
'.hbs{position:relative;width:100%;aspect-ratio:1/1;--hbs-c:#a855f7}' +
'.hbs-svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
'.hbs-slot{position:absolute;transform:translate(-50%,-50%);display:grid;place-items:center}' +
'.hbs-sat{position:relative;width:2.2em;height:2.2em;border-radius:50%;background:#fff;border:2px solid var(--hbs-c);display:grid;place-items:center;font-size:1.4em;line-height:1;box-shadow:0 4px 14px rgba(80,30,140,.14);box-sizing:border-box}' +
'.hbs-sat>:not(.hbs-ring):not(.hbs-cap){max-width:70%;max-height:70%}' +
'.hbs-hub{width:4.6em;height:4.6em;background:var(--hbs-c);border:0;color:#fff;font-weight:700;font-size:1em;box-shadow:0 6px 22px rgba(120,40,200,.35)}' +
'.hbs-ring{position:absolute;inset:0;border-radius:50%;border:2px solid var(--hbs-c);opacity:0;pointer-events:none}' +
'.hbs-cap{position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:.3em;font-size:.52em;font-weight:600;white-space:nowrap;opacity:.75}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function mk(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 kids = Array.from(el.children);
var hubSrc = kids.filter(function (k) { return k.hasAttribute('data-hub'); })[0] || kids[0];
var sats = kids.filter(function (k) { return k !== hubSrc; });
var captions = sats.some(function (k) { return k.dataset.label; });
var R = captions ? 33 : 37;
var s = (el._hbs = { spokes: [], visible: false, started: false, timer: null, last: -1 });
el.classList.add('hbs');
el.style.maxWidth = (+d.size || DEFAULTS.size) + 'px';
el.style.setProperty('--hbs-c', d.color || DEFAULTS.color);
var svg = mk('svg', { class: 'hbs-svg', viewBox: '0 0 100 100', 'aria-hidden': 'true' });
el.insertBefore(svg, el.firstChild);
var lines = mk('g', { stroke: d.color || DEFAULTS.color, 'stroke-width': 2, 'stroke-opacity': 0.45, 'stroke-linecap': 'round' }, svg);
s.dots = mk('g', { fill: d.color || DEFAULTS.color }, svg);
sats.forEach(function (k, i) {
var a = -Math.PI / 2 + (i / sats.length) * Math.PI * 2;
var x = 50 + Math.cos(a) * R, y = 50 + Math.sin(a) * R;
var slot = document.createElement('div');
slot.className = 'hbs-slot';
slot.style.left = x + '%';
slot.style.top = y + '%';
var sat = document.createElement('div');
sat.className = 'hbs-sat';
while (k.firstChild) sat.appendChild(k.firstChild);
if (k.dataset.label) {
var cap = document.createElement('span');
cap.className = 'hbs-cap';
cap.textContent = k.dataset.label;
sat.appendChild(cap);
sat.setAttribute('aria-label', k.dataset.label);
}
slot.appendChild(sat);
el.replaceChild(slot, k);
var line = mk('line', { x1: 50, y1: 50, x2: x, y2: y, 'vector-effect': 'non-scaling-stroke' }, lines);
s.spokes.push({ x: x, y: y, sat: sat, line: line });
sat.addEventListener('pointerenter', function () { if (s.started) pulse(el, i, 1); });
sat.addEventListener('pointerdown', function () { if (s.started) pulse(el, i, 1); });
});
var hubSlot = document.createElement('div');
hubSlot.className = 'hbs-slot';
hubSlot.style.left = hubSlot.style.top = '50%';
var hub = document.createElement('div');
hub.className = 'hbs-sat hbs-hub';
while (hubSrc.firstChild) hub.appendChild(hubSrc.firstChild);
var ring = document.createElement('span');
ring.className = 'hbs-ring';
ring.setAttribute('aria-hidden', 'true');
hub.appendChild(ring);
hubSlot.appendChild(hub);
el.replaceChild(hubSlot, hubSrc);
s.hub = hub;
s.ring = ring;
}
// Send a dot along spoke i: dir 1 = hub to icon, -1 = icon to hub.
function pulse(el, i, dir) {
var s = el._hbs, sp = s.spokes[i];
var from = dir > 0 ? [50, 50] : [sp.x, sp.y], to = dir > 0 ? [sp.x, sp.y] : [50, 50];
var halo = mk('circle', { cx: from[0], cy: from[1], r: 3.2, 'fill-opacity': 0.22 }, s.dots);
var core = mk('circle', { cx: from[0], cy: from[1], r: 1.5 }, s.dots);
var arrive = dir > 0 ? sp.sat : s.hub;
if (dir > 0) ripple(s);
anime.animate([halo, core], {
cx: [from[0], to[0]], cy: [from[1], to[1]], duration: 900, ease: 'inOut(2)',
onComplete: function () {
halo.remove();
core.remove();
anime.animate(arrive, { scale: [1, 1.14, 1], duration: 380, ease: 'out(2)' });
if (dir < 0) ripple(s);
}
});
}
function ripple(s) {
anime.animate(s.ring, { scale: [1, 1.7], opacity: [0.6, 0], duration: 700, ease: 'out(3)' });
}
function tick(el) {
var s = el._hbs, n = s.spokes.length;
if (!n || !s.visible) return;
var i;
do { i = Math.floor(Math.random() * n); } while (n > 1 && i === s.last);
s.last = i;
var mode = el.dataset.direction || DEFAULTS.direction;
pulse(el, i, mode === 'in' ? -1 : mode === 'out' ? 1 : Math.random() < 0.5 ? 1 : -1);
var every = +el.dataset.interval || DEFAULTS.interval;
s.timer = setTimeout(function () { tick(el); }, every * (0.7 + Math.random() * 0.6));
}
function intro(el) {
var s = el._hbs;
anime.animate(s.hub, { scale: [0, 1], opacity: [0, 1], duration: 600, ease: 'outBack(1.8)' });
s.spokes.forEach(function (sp, i) {
var delay = 350 + i * 110;
anime.animate(sp.line, { x2: [50, sp.x], y2: [50, sp.y], opacity: [0, 1], duration: 450, delay: delay, ease: 'out(2)' });
anime.animate(sp.sat, { scale: [0, 1], opacity: [0, 1], duration: 500, delay: delay + 330, ease: 'outBack(2)' });
});
setTimeout(function () {
s.started = true;
clearTimeout(s.timer);
tick(el);
}, 350 + s.spokes.length * 110 + 700);
}
function init() {
var still = reduced();
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._hbs;
s.visible = e.isIntersecting;
clearTimeout(s.timer);
if (!s.visible) return;
if (!s.introduced) { s.introduced = true; intro(el); }
else if (s.started) tick(el);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-hub-and-spoke]').forEach(function (el) {
if (el._hbs) return;
setup(el);
if (still) return;
var s = el._hbs;
anime.utils.set(s.hub, { scale: 0, opacity: 0 });
s.spokes.forEach(function (sp) {
anime.utils.set(sp.line, { x2: 50, y2: 50, opacity: 0 });
anime.utils.set(sp.sat, { scale: 0, opacity: 0 });
});
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-hub-and-spoke>
<span data-hub>API</span>
<span data-label="Phone">๐ฑ</span>
<span data-label="Laptop">๐ป</span>
<span data-label="Cloud">โ๏ธ</span>
<span data-label="Email">โ๏ธ</span>
</div>