Avatar Stack Spread
A row of overlapping avatars springs apart when the pointer comes over it, so every face can be seen. The one you point at lifts up and shows its name, and they tuck back together when you leave.
ARBKCMDOEL+4
Point at the faces, or Tab to them and use the arrow keys
How to use it
- Paste the snippet just before
</head>. - Add
data-avatar-stack-spreadto a container of avatars, each with a name inalt,title,aria-labelordata-name.
Options
Add any of these attributes to the same element.
data-size | Avatar size. Default 48 px. |
data-overlap | Share of each avatar tucked under the next. Default 0.4. |
data-gap | Space between them when spread. Default 8 px. |
data-ring | Colour of the ring round each one. Default white. |
data-autoplay | Milliseconds: spread and point at faces on a timer until someone interacts. |
The stack is focusable: focus spreads it and the arrow keys move between faces. On touch screens a tap spreads or gathers it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Avatar Stack Spread: a row of overlapping avatars springs apart when the
// pointer comes over it (or it gets keyboard focus), so every face can be
// seen. The one under the pointer lifts up and shows its name; they tuck
// back together on leave.
// Usage:
// <div data-avatar-stack-spread>
// <img src="ana.jpg" alt="Ana">
// <img src="ben.jpg" alt="Ben">
// <img src="cai.jpg" alt="Cai">
// <span>+4</span>
// </div>
// Each child is an avatar; its name comes from alt, title, aria-label or
// data-name. Optional: data-size (px, default 48), data-overlap (share of
// an avatar hidden under the next, default 0.4), data-gap (px between them
// when spread, default 8), data-ring (colour of the ring round each, default
// white), data-autoplay (ms: spread and point at faces on a timer until
// someone interacts). With the keyboard, the arrow keys move between faces.
var DEFAULTS = { size: 48, overlap: 0.4, gap: 8, ring: '#fff' };
var css =
'[data-avatar-stack-spread]{--ass-size:48px;position:relative;display:inline-flex;align-items:center;padding:4px;border-radius:999px;outline:none}' +
'[data-avatar-stack-spread]>:not(.ass-tip){position:relative;flex:none;width:var(--ass-size);height:var(--ass-size);border-radius:50%;object-fit:cover;' +
'box-shadow:0 0 0 3px var(--ass-ring,#fff),0 4px 10px rgba(15,15,30,.12);will-change:transform}' +
// Keyboard focus rings the face that's lifted, wherever it has moved to.
'[data-avatar-stack-spread]:focus-visible>.ass-on{box-shadow:0 0 0 3px var(--ass-ring,#fff),0 0 0 6px var(--accent,#6a0dad)}' +
'.ass-tip{position:absolute;left:0;bottom:100%;z-index:50;padding:4px 10px;border-radius:999px;background:#0a0a0b;color:#fff;font:600 12px/1.5 system-ui,sans-serif;white-space:nowrap;pointer-events:none;opacity:0;visibility:hidden}' +
'.ass-tip::after{content:"";position:absolute;left:50%;top:100%;margin-left:-5px;border:5px solid transparent;border-top-color:#0a0a0b}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function nameOf(a) {
return a.getAttribute('alt') || a.getAttribute('title') || a.getAttribute('aria-label') || a.getAttribute('data-name') || '';
}
function setup(el) {
var d = el.dataset;
var faces = Array.from(el.children);
var n = faces.length;
if (!n) return;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var size = +d.size || DEFAULTS.size;
var overlap = d.overlap !== undefined ? +d.overlap : DEFAULTS.overlap;
var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
var inThumb = !!el.closest('[aria-hidden="true"]');
var open = false, lifted = -1, stopped = false, paused = false, timer = null;
el.style.setProperty('--ass-size', size + 'px');
if (d.ring) el.style.setProperty('--ass-ring', d.ring);
var over = size * overlap;
faces.forEach(function (f, i) {
if (i) f.style.marginLeft = -over + 'px';
// Later faces sit on top, as in a pile of photos.
f.style.zIndex = i + 1;
});
var tip = document.createElement('span');
tip.className = 'ass-tip';
tip.setAttribute('aria-hidden', 'true');
el.appendChild(tip);
if (!inThumb) {
var names = faces.map(nameOf).filter(Boolean);
if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
if (!el.getAttribute('role')) el.setAttribute('role', 'group');
if (!el.getAttribute('aria-label') && names.length) el.setAttribute('aria-label', names.join(', '));
}
// Spread: each face moves out from the middle so the gaps open evenly.
function place(i, now) {
var f = faces[i], step = over + gap, mid = (n - 1) / 2;
var up = i === lifted;
var props = open
? { x: (i - mid) * step, y: up ? -size * 0.16 : 0, scale: up ? 1.14 : 1, rotate: up ? 0 : (i - mid) * 2 }
: { x: 0, y: 0, scale: 1, rotate: 0 };
if (now || reduced) return anime.utils.set(f, props);
props.duration = open ? 700 : 450;
props.ease = open ? 'outElastic(1, .65)' : 'out(4)';
if (i !== lifted) props.delay = open ? Math.abs(i - mid) * 30 : 0;
anime.animate(f, props);
}
function layout(now) { for (var i = 0; i < n; i++) place(i, now); }
function showTip(i) {
var name = i >= 0 ? nameOf(faces[i]) : '';
if (!name) {
anime.animate(tip, { opacity: 0, duration: reduced ? 0 : 150, onComplete: function () { tip.style.visibility = 'hidden'; } });
return;
}
tip.textContent = name;
tip.style.visibility = 'visible';
// Centre it over the face's spread-out spot.
var mid = (n - 1) / 2, step = over + gap;
var cx = faces[i].offsetLeft + size / 2 + (i - mid) * step;
anime.utils.set(tip, { x: cx - tip.offsetWidth / 2 });
anime.animate(tip, { opacity: [0, 1], y: [-size * 0.16, -size * 0.16 - 10], duration: reduced ? 0 : 260, ease: 'out(3)' });
}
function lift(i) {
if (i === lifted) return;
var was = lifted;
lifted = i;
if (was >= 0) { faces[was].classList.remove('ass-on'); place(was, false); }
if (i >= 0) {
faces[i].classList.add('ass-on');
faces[i].style.zIndex = n + 1;
place(i, false);
}
if (was >= 0) faces[was].style.zIndex = was + 1;
showTip(i);
}
function set(next) {
next = !!next;
if (next === open) return;
open = next;
if (!open) lift(-1);
layout(false);
}
function stop() { stopped = true; clearInterval(timer); }
layout(true);
// Pointer: spread on enter, lift the face under the pointer.
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { paused = true; set(true); } });
el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { paused = false; set(false); } });
faces.forEach(function (f, i) {
f.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch' && open) lift(i); });
});
el.addEventListener('pointerleave', function () { lift(-1); });
// Touch screens have no hover: a tap spreads them or gathers them.
el.addEventListener('pointerup', function (e) { if (e.pointerType === 'touch') { stop(); set(!open); } });
if (!inThumb) {
el.addEventListener('focus', function () { stop(); set(true); lift(0); });
el.addEventListener('blur', function () { set(false); });
el.addEventListener('keydown', function (e) {
var k = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!k || !open) return;
e.preventDefault();
lift((Math.max(0, lifted) + k + n) % n);
});
}
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced) {
var every = +d.autoplay || 1600, beat = 0;
new IntersectionObserver(function (entries) {
clearInterval(timer);
if (!entries[0].isIntersecting || stopped) return;
// Spread, point at a couple of faces, gather, rest.
timer = setInterval(function () {
if (paused || el.closest('[hidden]')) return;
var step = beat++ % 4;
if (step === 0) set(true);
else if (step < 3) lift(Math.floor(Math.random() * n));
else set(false);
}, every / 2);
}).observe(el);
}
}
function init() {
document.querySelectorAll('[data-avatar-stack-spread]').forEach(function (el) {
if (el._ass) return;
el._ass = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-avatar-stack-spread>
<img src="ana.jpg" alt="Ana">
<img src="ben.jpg" alt="Ben">
<img src="cai.jpg" alt="Cai">
<span>+4</span>
</div>