Avatar Stack
Overview
Overlapping member avatars that fan out on hover with name bubbles and a +N count at the end. Avatars are initials on gradients, no images needed.
Plain HTML, CSS and a little vanilla JS, with no framework dependency. Copy the prompt into Claude Code, Cursor, Trae or Codex and the model adds Avatar Stack to your project in your colors.
Where to use it
Dashboards, data tables, chart cards, comparison tables and status panels.
Install
With Builder, install it with the 8pt CLI or call it directly from an editor connected to 8pt MCP.
$ npm i -g @8pt-dev/cli && 8pt add avatar-stack
mcp › Use 8pt MCP to call get_component("avatar-stack")
Code
Source for Avatar Stack. Motion uses only transform and opacity and respects the Reduce motion setting.
<div class="x-av">
<div class="x-av__row">
<button type="button" style="--c1:#7b6bff;--c2:#00c2ff" data-n="Ava Chen">AC</button>
<button type="button" style="--c1:#ff7a59;--c2:#ffcc4d" data-n="Leo Park">LP</button>
<button type="button" style="--c1:#21c58e;--c2:#7ef0c4" data-n="Mia Rossi">MR</button>
<button type="button" style="--c1:#e05bff;--c2:#ff8ad8" data-n="Noah Kim">NK</button>
<button type="button" style="--c1:#4d74ff;--c2:#8fb0ff" data-n="Zoe Liu">ZL</button>
<span class="x-av__more">+12</span>
</div>
<p class="x-av__cap">17 members editing</p>
</div>.x-av{display:grid;justify-items:center;gap:14px;color:var(--d-fg,#fff)}
.x-av__row{display:flex;align-items:center;padding:6px 10px}
.x-av__row>*{position:relative;flex:none;display:grid;place-items:center;width:48px;height:48px;margin-left:-14px;border-radius:50%;border:3px solid var(--d-bg,#09090d);font:700 13px/1 var(--d-font,system-ui,sans-serif);transition:transform .4s cubic-bezier(.3,1.3,.5,1)}
.x-av__row>*:first-child{margin-left:0}
.x-av__row button{padding:0;color:#fff;background:linear-gradient(135deg,var(--c1),var(--c2));cursor:pointer}
.x-av__more{background:var(--d-card,#14141c);color:var(--d-muted,#9b9ba8);font-size:12px}
.x-av__row button::after{content:attr(data-n);position:absolute;left:50%;bottom:calc(100% + 8px);padding:5px 8px;border-radius:7px;background:var(--d-fg,#fff);color:var(--d-bg,#000);font:600 11px/1 var(--d-font,system-ui,sans-serif);white-space:nowrap;opacity:0;transform:translate(-50%,4px);transition:opacity .2s,transform .2s;pointer-events:none}
.x-av__row button:hover::after,.x-av__row button:focus-visible::after{opacity:1;transform:translate(-50%,0)}
.x-av__row:hover>*,.x-av__row.open>*{transform:translateX(calc(var(--i) * 10px))}
.x-av__row>*:hover{z-index:2}
.x-av__row button:hover{transform:translateX(calc(var(--i) * 10px)) translateY(-4px)}
.x-av__cap{margin:0;display:flex;align-items:center;gap:8px;font:12.5px/1 var(--d-font,system-ui,sans-serif);color:var(--d-muted,#9b9ba8)}
…const init = (root) => {
const row = root.querySelector('.x-av__row');
[...row.children].forEach((el, i) => el.style.setProperty('--i', i));
// breathe open/closed once so the preview shows the interaction
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let t1 = setTimeout(() => row.classList.add('open'), 900), t2 = setTimeout(() => row.classList.remove('open'), 2400);
return () => { clearTimeout(t1); clearTimeout(t2); };
};Subscribe to Builder to see the full source.See pricing