Orbiting Avatars
Avatars circle round a central logo on dotted rings, each ring turning its own way at its own pace. They stay upright as they go, pop in when it scrolls into view, and the centre sends out a soft pulse every few seconds.
a
AR
BK
CM
DO
EL
FN
GP
HS
IV
JT
How to use it
- Paste the snippet just before
</head>. - Add
data-orbiting-avatarsto a box with a width, holding your logo and your avatars. The box is made square. - Mark the logo with
data-center(or put it first). Everything else goes round.
Options
Add any of these attributes to the same element.
data-rings | How many rings to share the avatars between. Default 2. |
data-speed | Seconds for the outer ring to go round once. Default 30. |
data-direction | alternate, clockwise or anticlockwise. Default alternate. |
data-pulse="false" | No pulse from the centre. |
data-pause="false" | Keep turning while a mouse is over it. By default it slows to a stop so people can look. |
The bigger rings get more avatars. The ring colour follows the text colour; set --oav-ring to change it. It only moves while on screen, and with reduced motion the avatars sit still on their rings.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Orbiting Avatars: avatars circle round a central logo on dotted rings,
// each ring turning its own way at its own pace. The avatars stay upright
// as they go, pop in when it scrolls into view, and the centre gives off a
// soft pulse now and then.
// Usage: <div data-orbiting-avatars style="width: 320px">
// <img data-center src="logo.svg" alt="Our logo">
// <img src="ana.jpg" alt="Ana"> <img src="ben.jpg" alt="Ben"> …
// </div>
// The element with data-center (or else the first child) sits in the
// middle; every other child goes round. The box is made square.
// Optional: data-rings (how many rings, default 2), data-speed (seconds
// for the outer ring to go round once, default 30), data-direction
// ("alternate", default, "clockwise" or "anticlockwise"), data-pulse="false"
// (no pulse from the centre), data-pause="false" (keep turning while a
// mouse is over it).
var DEFAULTS = { rings: 2, speed: 30 };
var css =
'.oav{position:relative;aspect-ratio:1;--oav-ring:currentColor}' +
'.oav>*{position:absolute;left:50%;top:50%;translate:-50% -50%;margin:0}' +
'.oav>.oav-av{transition:scale .25s ease;will-change:translate;z-index:2}' +
'.oav>.oav-av:hover{scale:1.15;z-index:3}' +
'.oav>.oav-c{z-index:4}' +
'.oav>.oav-ring{border:1.5px dashed var(--oav-ring);border-radius:50%;opacity:.22;pointer-events:none;box-sizing:border-box}' +
'.oav>.oav-pulse{border-radius:50%;background:var(--oav-ring);opacity:0;pointer-events:none;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 setup(el) {
var d = el.dataset;
var kids = Array.prototype.slice.call(el.children);
if (kids.length < 2) return;
var center = el.querySelector(':scope > [data-center]') || kids[0];
var avs = kids.filter(function (k) { return k !== center; });
el.classList.add('oav');
center.classList.add('oav-c');
var nr = Math.max(1, Math.min(avs.length, parseInt(d.rings, 10) || DEFAULTS.rings));
var mode = d.direction || 'alternate';
// Relative ring sizes: the outer ring is 1, the inner ones step in.
var rel = [];
for (var k = 0; k < nr; k++) rel.push(nr === 1 ? 1 : 0.58 + 0.42 * k / (nr - 1));
var relSum = rel.reduce(function (a, b) { return a + b; }, 0);
// Share the avatars out, more on the bigger rings.
var rings = [], given = 0;
for (k = 0; k < nr; k++) {
var count = k === nr - 1 ? avs.length - given : Math.max(1, Math.round(avs.length * rel[k] / relSum));
var ring = document.createElement('span');
ring.className = 'oav-ring';
ring.setAttribute('aria-hidden', 'true');
el.insertBefore(ring, el.firstChild);
var dir = mode === 'clockwise' ? 1 : mode === 'anticlockwise' ? -1 : (k % 2 ? -1 : 1);
var list = avs.slice(given, given + count);
given += count;
rings.push({ rel: rel[k], dir: dir, el: ring, avs: list, offset: k * 0.7 });
list.forEach(function (a) { a.classList.add('oav-av'); });
}
var pulse = null;
if (d.pulse !== 'false') {
pulse = document.createElement('span');
pulse.className = 'oav-pulse';
pulse.setAttribute('aria-hidden', 'true');
el.insertBefore(pulse, center);
}
var s = el._oavS = { rings: rings, t: 0, R: 0, last: 0, raf: 0, visible: false, hover: false, pulse: pulse, center: center };
var period = (+d.speed || DEFAULTS.speed) * 1000;
function measure() {
var size = el.clientWidth;
var biggest = 0;
avs.forEach(function (a) { biggest = Math.max(biggest, a.offsetWidth, a.offsetHeight); });
s.R = Math.max(10, size / 2 - biggest / 2 - 2);
rings.forEach(function (r) {
var dia = r.rel * s.R * 2;
r.el.style.width = r.el.style.height = dia + 'px';
});
if (pulse) pulse.style.width = pulse.style.height = Math.max(center.offsetWidth, center.offsetHeight) + 'px';
draw();
}
function draw() {
rings.forEach(function (r) {
var n = r.avs.length, rad = r.rel * s.R;
// Inner rings turn a little faster, so they drift past each other.
var turn = r.dir * (s.t / (period * (0.55 + 0.45 * r.rel))) * Math.PI * 2;
r.avs.forEach(function (a, i) {
var ang = r.offset + turn + (i / n) * Math.PI * 2 - Math.PI / 2;
// The orbit goes on translate, leaving transform free for the pop-in.
a.style.translate = 'calc(' + (Math.cos(ang) * rad).toFixed(2) + 'px - 50%) calc(' + (Math.sin(ang) * rad).toFixed(2) + 'px - 50%)';
});
});
}
function frame(now) {
s.raf = 0;
if (!s.visible) return;
var dt = s.last ? Math.min(64, now - s.last) : 0;
s.last = now;
// Ease the speed down while a mouse is over it, and back up after.
s.rate = (s.rate === undefined ? 1 : s.rate) + ((s.hover ? 0 : 1) - (s.rate === undefined ? 1 : s.rate)) * Math.min(1, dt / 250);
s.t += dt * s.rate;
draw();
s.raf = requestAnimationFrame(frame);
}
measure();
new ResizeObserver(measure).observe(el);
if (reduced()) return;
if (d.pause !== 'false') {
el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') s.hover = true; });
el.addEventListener('pointerleave', function () { s.hover = false; });
}
// Hidden until it scrolls into view, then everything pops in.
anime.utils.set(rings.map(function (r) { return r.el; }), { scale: 0.6, opacity: 0 });
anime.utils.set(avs, { scale: 0, opacity: 0 });
anime.utils.set(center, { scale: 0.5, opacity: 0 });
var entered = false;
new IntersectionObserver(function (en) {
s.visible = en[0].isIntersecting;
if (s.visible) {
s.last = 0;
if (!s.raf) s.raf = requestAnimationFrame(frame);
if (!entered) { entered = true; enter(); }
}
}, { threshold: 0.2 }).observe(el);
function enter() {
anime.animate(center, { scale: 1, opacity: 1, duration: 700, ease: 'outBack(2)' });
anime.animate(rings.map(function (r) { return r.el; }), { scale: 1, opacity: 0.22, duration: 800, delay: anime.stagger(120, { start: 150 }), ease: 'out(3)' });
anime.animate(avs, { scale: 1, opacity: 1, duration: 600, delay: anime.stagger(70, { start: 350 }), ease: 'outBack(2.2)' });
if (pulse) {
anime.animate(pulse, {
scale: [1, 2.6], opacity: [0.28, 0],
duration: 1800, delay: 1200, loopDelay: 1600, loop: true, ease: 'out(2)'
});
}
}
}
function init() {
document.querySelectorAll('[data-orbiting-avatars]').forEach(function (el) {
if (el._oav) return;
el._oav = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-orbiting-avatars style="width: 320px">
<img data-center src="logo.svg" alt="Our logo" width="72">
<img src="ana.jpg" alt="Ana" width="44">
<img src="ben.jpg" alt="Ben" width="44">
<img src="cai.jpg" alt="Cai" width="44">
<!-- … as many as you like -->
</div>