{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar-stack",
  "type": "registry:block",
  "title": "Fan-out Avatar Stack",
  "description": "An overlapping avatar stack that fans out on springs when hovered: the avatar under the pointer rises, its neighbours lean away and one tooltip glides between them, tilting with its speed. A +N chip deals out the rest.",
  "docs": "Fan-out Avatar Stack was added to public/vividsites/avatar-stack.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/avatar-stack",
  "categories": [
    "ui",
    "media"
  ],
  "files": [
    {
      "path": "components/vividsites/avatar-stack.html",
      "type": "registry:file",
      "target": "public/vividsites/avatar-stack.html",
      "content": "<!--\nvs-avatar-stack: an overlapping avatar stack that fans out on hover. Every avatar rides its\n  own spring: the stack spreads apart, the one under the pointer rises and grows while its\n  neighbours lean away, and a single tooltip glides between them, tilting with its speed, to\n  show name, role and presence. Past data-max the rest fold into a \"+N\" chip that deals them\n  out one by one when clicked. On touch, press and slide along the stack to scrub the\n  tooltip. Each avatar is a button: Tab or ←/→ move through them, and focus shows the tooltip.\n\n  Markup:  <div class=\"vs-avatar-stack\" data-max=\"5\" aria-label=\"Team\">\n             <button class=\"vs-avatar-stack-item\" data-name=\"Inès Varga\" data-role=\"Design lead\"\n                     data-status=\"online\"><img src=\"...\" alt=\"\"></button> ...\n           </div>\n  Attributes on the stack: data-max (visible before the \"+N\" chip, default 5), data-anchor\n    (\"start\" fans out to the right, default; \"center\" grows both ways and keeps its middle still);\n    on an item: data-name, data-role, data-status (\"online\" | \"away\" | \"busy\", shows a dot).\n  Modifier: .vs-avatar-stack--sm tightens the tooltip for small stacks.\n  CSS variables: --vs-avatar-stack-size, -overlap (0–.6), -spread (fanned gap), -ring (match your\n    background), -tip-bg, -tip-ink, -online, -font. After changing them from JS call refresh().\n  Events: \"select\" with detail { name, index } when an avatar is clicked.\n  JS: el.__vsAvatarStack.fan(bool) / open(bool) / hover(i) / refresh(); vsAvatarStack.mount(el).\n  Reduced motion: positions snap instead of springing.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500&display=swap\" rel=\"stylesheet\">\n<div class=\"vs-avatar-stack\" data-anchor=\"center\" data-max=\"5\" aria-label=\"Team\">\n  <button class=\"vs-avatar-stack-item\" data-name=\"Inès Varga\" data-role=\"Design lead\" data-status=\"online\"><img src=\"assets/riposte.webp\" alt=\"\"></button>\n  <button class=\"vs-avatar-stack-item\" data-name=\"Marek Holt\" data-role=\"Founder\"><img src=\"assets/theo.webp\" alt=\"\"></button>\n  <button class=\"vs-avatar-stack-item\" data-name=\"Tane Rewi\" data-role=\"Head of ops\" data-status=\"online\"><img src=\"assets/coach.webp\" alt=\"\"></button>\n  <button class=\"vs-avatar-stack-item\" data-name=\"Jonah Park\" data-role=\"Engineer\"><img src=\"assets/ounce.webp\" alt=\"\"></button>\n  <button class=\"vs-avatar-stack-item\" data-name=\"Luca Ferro\" data-role=\"Brand\" data-status=\"away\"><img src=\"assets/worn.webp\" alt=\"\"></button>\n  <button class=\"vs-avatar-stack-item\" data-name=\"Tove Amdal\" data-role=\"Research\"><img src=\"assets/grower.webp\" alt=\"\"></button>\n  <button class=\"vs-avatar-stack-item\" data-name=\"Signe Aro\" data-role=\"Product\" data-status=\"online\"><img src=\"assets/ambit.webp\" alt=\"\"></button>\n</div>\n\n<style>\n.vs-avatar-stack {\n  --vs-avatar-stack-size: 56px;\n  --vs-avatar-stack-overlap: .32;                /* fraction of each avatar hidden under the next, collapsed */\n  --vs-avatar-stack-spread: 8px;                 /* gap between avatars when fanned out */\n  --vs-avatar-stack-ring: #eef1f5;               /* ring around each avatar: match your background */\n  --vs-avatar-stack-tip-bg: #121417;\n  --vs-avatar-stack-tip-ink: #f5f6f8;\n  --vs-avatar-stack-online: #2fcf7a;\n  --vs-avatar-stack-font: \"Inter Tight\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n\n  position: relative; display: inline-block; height: var(--vs-avatar-stack-size); vertical-align: middle;\n  font-family: var(--vs-avatar-stack-font); -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent;\n}\n.vs-avatar-stack *, .vs-avatar-stack *::before, .vs-avatar-stack *::after { box-sizing: border-box; }\n.vs-avatar-stack-item, .vs-avatar-stack-more {\n  all: unset; box-sizing: border-box; position: absolute; left: 0; top: 0; width: var(--vs-avatar-stack-size); height: var(--vs-avatar-stack-size);\n  border-radius: 50%; cursor: pointer; will-change: transform; transform-origin: 50% 60%;\n}\n.vs-avatar-stack-item img {\n  width: 100%; height: 100%; border-radius: 50%; object-fit: cover; display: block; -webkit-user-drag: none; background: #cfd5dd;\n  box-shadow: 0 0 0 calc(var(--vs-avatar-stack-size) * .05 + 1px) var(--vs-avatar-stack-ring), 0 calc(var(--vs-avatar-stack-size) * .08) calc(var(--vs-avatar-stack-size) * .22) calc(var(--vs-avatar-stack-size) * -.08) rgba(16,22,34,.35);\n}\n.vs-avatar-stack-item[data-status]::after {\n  content: \"\"; position: absolute; right: 2%; bottom: 2%; width: 24%; height: 24%; min-width: 7px; min-height: 7px; border-radius: 50%;\n  background: var(--vs-avatar-stack-online); box-shadow: 0 0 0 calc(var(--vs-avatar-stack-size) * .04 + 1px) var(--vs-avatar-stack-ring);\n}\n.vs-avatar-stack-item[data-status=\"away\"]::after { background: #f2b33d; }\n.vs-avatar-stack-item[data-status=\"busy\"]::after { background: #f0524f; }\n.vs-avatar-stack-item:focus-visible img, .vs-avatar-stack-more:focus-visible {\n  box-shadow: 0 0 0 calc(var(--vs-avatar-stack-size) * .05 + 1px) var(--vs-avatar-stack-ring), 0 0 0 calc(var(--vs-avatar-stack-size) * .05 + 3px) #3b6cff;\n}\n.vs-avatar-stack-item[hidden] { display: block !important; pointer-events: none; }\n.vs-avatar-stack-more {\n  display: grid; place-items: center; font-size: calc(var(--vs-avatar-stack-size) * .3); font-weight: 500; letter-spacing: -.02em; font-variant-numeric: tabular-nums;\n  color: #2a2f38; background: #dde2ea;\n  box-shadow: 0 0 0 calc(var(--vs-avatar-stack-size) * .05 + 1px) var(--vs-avatar-stack-ring), inset 0 0 0 .5px rgba(0,0,0,.06);\n  transition: background-color .15s cubic-bezier(.2,0,0,1);\n}\n.vs-avatar-stack-more:hover { background: #d1d7e1; }\n.vs-avatar-stack-more span { display: block; transition: transform .45s cubic-bezier(.3,1.5,.5,1); }\n\n/* tooltip */\n.vs-avatar-stack-tip {\n  position: absolute; left: 0; bottom: calc(100% + 12px); z-index: 50; pointer-events: none; white-space: nowrap;\n  padding: 8px 12px 9px; border-radius: 12px; display: grid; gap: 2px; text-align: center; transform-origin: 50% 110%;\n  color: var(--vs-avatar-stack-tip-ink); background: var(--vs-avatar-stack-tip-bg);\n  box-shadow: 0 0 0 .5px rgba(255,255,255,.08) inset, 0 1px 0 rgba(255,255,255,.08) inset, 0 12px 28px -10px rgba(10,14,24,.55), 0 3px 8px -2px rgba(10,14,24,.25);\n  opacity: 0; will-change: transform, opacity;\n}\n.vs-avatar-stack-tip::after {\n  content: \"\"; position: absolute; left: 50%; bottom: -5px; width: 11px; height: 11px; margin-left: -5.5px; border-radius: 0 0 3px 0;\n  background: inherit; transform: rotate(45deg);\n}\n.vs-avatar-stack-tip b { font-size: 14px; font-weight: 500; letter-spacing: -.01em; }\n.vs-avatar-stack-tip span { font-size: 12px; opacity: .6; }\n.vs-avatar-stack-tip i { font-style: normal; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--vs-avatar-stack-online); margin-right: 6px; vertical-align: 1px; }\n.vs-avatar-stack--sm .vs-avatar-stack-tip { padding: 6px 10px 7px; border-radius: 10px; }\n.vs-avatar-stack--sm .vs-avatar-stack-tip b { font-size: 13px; }\n</style>\n\n<script>\n(() => {\n  const RM = matchMedia('(prefers-reduced-motion: reduce)');\n  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));\n  class S { // a spring\n    constructor(v, k, z) { this.v = this.t = v; this.vel = 0; this.k = k; this.z = z; }\n    step(dt) {\n      if (RM.matches) { this.v = this.t; this.vel = 0; return false; }\n      const c = 2 * Math.sqrt(this.k) * this.z; this.vel += (-this.k * (this.v - this.t) - c * this.vel) * dt; this.v += this.vel * dt;\n      if (Math.abs(this.v - this.t) < .002 && Math.abs(this.vel) < .01) { this.v = this.t; this.vel = 0; return false; } return true;\n    }\n  }\n\n  function mount(root) {\n    if (root.__vsAvatarStack) return root.__vsAvatarStack;\n    const $ = (t, c, h) => { const e = document.createElement(t); if (c) e.className = c; if (h != null) e.innerHTML = h; return e; };\n    const els = [...root.querySelectorAll(':scope > .vs-avatar-stack-item')];\n    const max = Math.max(1, parseInt(root.dataset.max, 10) || 5);\n    root.setAttribute('role', root.getAttribute('role') || 'group');\n    const items = els.map((el, i) => {\n      el.type = el.type || 'button'; if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', [el.dataset.name, el.dataset.role].filter(Boolean).join(', '));\n      return { el, i, x: new S(0, 360, .74), y: new S(0, 420, .56), s: new S(1, 420, .58), o: new S(1, 320, 1), showAt: 0 };\n    });\n    const extra = Math.max(0, items.length - max);\n    let more = null;\n    if (extra) {\n      more = $('button', 'vs-avatar-stack-more', `<span>+${extra}</span>`); more.type = 'button';\n      more.setAttribute('aria-label', `Show ${extra} more`); more.setAttribute('aria-expanded', 'false'); root.append(more);\n      items.push({ el: more, i: items.length, more: true, x: new S(0, 360, .74), y: new S(0, 420, .56), s: new S(1, 420, .58), o: new S(1, 320, 1), showAt: 0 });\n    }\n    const tip = $('div', 'vs-avatar-stack-tip', '<b></b><span></span>'); tip.setAttribute('role', 'tooltip'); tip.id = 'vsas-' + Math.random().toString(36).slice(2, 8); root.append(tip);\n    const T = { x: new S(0, 300, .62), o: new S(0, 380, 1), s: new S(.6, 420, .6) };\n\n    let baseW = 0, fan = false, open = false, hov = -1, raf = 0, last = 0, size = 56, overlap = .32, spread = 8, tipIdx = -1, hideT = 0;\n    const read = () => { const cs = getComputedStyle(root); size = root.offsetHeight || parseFloat(cs.getPropertyValue('--vs-avatar-stack-size')) || 56; overlap = parseFloat(cs.getPropertyValue('--vs-avatar-stack-overlap')) || .32; spread = parseFloat(cs.getPropertyValue('--vs-avatar-stack-spread')); if (isNaN(spread)) spread = 8; };\n    const people = () => items.filter(it => !it.more);\n    const visible = () => { const p = people(); const v = open ? p : p.slice(0, max); return more ? [...v, items[items.length - 1]] : v; };\n\n    function targets() {\n      const vis = visible(), now = performance.now(), on = fan || open;\n      // never fan wider than the container\n      const par = root.parentElement, pcs = par && getComputedStyle(par);\n      const avail = (par ? par.clientWidth - parseFloat(pcs.paddingLeft) - parseFloat(pcs.paddingRight) : innerWidth) - 8;\n      const fit = vis.length > 1 ? (avail - size) / (vis.length - 1) : Infinity;\n      const step = on ? Math.max(size * (1 - overlap), Math.min(size + spread, fit)) : size * (1 - overlap);\n      const hv = vis.indexOf(items[hov]);\n      // the box keeps its collapsed width; data-anchor=\"center\" fans out both ways, \"start\" to the right\n      baseW = (vis.length - 1) * size * (1 - overlap) + size;\n      const shift = root.dataset.anchor === 'center' ? ((vis.length - 1) * step + size - baseW) / 2 : 0;\n      vis.forEach((it, k) => {\n        let x = k * step - shift, y = 0, s = 1;\n        if (on && hv >= 0 && k !== hv) { const d = k - hv; x += Math.sign(d) * spread * .9 * Math.exp(-(Math.abs(d) - 1) * .9); }\n        if (k === hv) { y = -size * .1; s = 1.1; }\n        it.x.t = x; it.y.t = y; it.s.t = now >= it.showAt ? s : .4; it.o.t = now >= it.showAt ? 1 : 0;\n        it.el.style.zIndex = k === hv ? 40 : it.more ? 31 : String(30 - k); it.el.style.pointerEvents = ''; it.el.tabIndex = 0;\n      });\n      const chip = more ? items[items.length - 1] : null;\n      people().forEach(it => { if (vis.includes(it)) return; it.x.t = chip ? chip.x.t : 0; it.y.t = 0; it.s.t = .4; it.o.t = 0; it.el.style.pointerEvents = 'none'; it.el.tabIndex = -1; it.el.style.zIndex = '1'; });\n      const tipOn = hov >= 0 && !items[hov].more && vis.includes(items[hov]);\n      if (tipOn) { T.x.t = items[hov].x.t + size / 2; T.o.t = 1; T.s.t = 1; } else { T.o.t = 0; T.s.t = .7; }\n    }\n    function render() {\n      for (const it of items) {\n        it.el.style.transform = `translate3d(${it.x.v.toFixed(2)}px, ${it.y.v.toFixed(2)}px, 0) scale(${it.s.v.toFixed(4)})`;\n        it.el.style.opacity = clamp(it.o.v, 0, 1).toFixed(3);\n      }\n      root.style.width = baseW + 'px';\n      const tilt = clamp(T.x.vel * .018, -12, 12), tw = tip.offsetWidth;\n      tip.style.transform = `translateX(${(T.x.v - tw / 2).toFixed(2)}px) rotate(${tilt.toFixed(2)}deg) scale(${T.s.v.toFixed(4)})`;\n      tip.style.opacity = clamp(T.o.v, 0, 1).toFixed(3);\n    }\n    function frame(ts) {\n      raf = 0; const dt = Math.min(.032, (ts - (last || ts)) / 1000); last = ts; targets();\n      let moving = false; for (const it of items) for (const k of ['x', 'y', 's', 'o']) moving = it[k].step(dt) || moving;\n      for (const k in T) moving = T[k].step(dt) || moving;\n      const pending = items.some(it => it.showAt > performance.now());\n      render(); if (moving || pending) raf = requestAnimationFrame(frame); else last = 0;\n    }\n    const kick = () => { if (!raf) raf = requestAnimationFrame(frame); };\n    function setHover(i) {\n      if (i === hov) return; hov = i;\n      const it = items[i];\n      if (it && !it.more) {\n        if (tipIdx < 0 || T.o.v < .05) { T.x.v = it.x.t + size / 2; T.x.vel = 0; } // appear in place, glide after\n        tipIdx = i; tip.querySelector('b').innerHTML = (it.el.dataset.status === 'online' ? '<i></i>' : '') + (it.el.dataset.name || '');\n        tip.querySelector('span').textContent = it.el.dataset.role || ''; tip.querySelector('span').hidden = !it.el.dataset.role;\n        items.forEach(q => q.el.removeAttribute('aria-describedby')); it.el.setAttribute('aria-describedby', tip.id);\n      }\n      kick();\n    }\n    function setOpen(v) {\n      open = v; if (!more) return; const now = performance.now(); more.setAttribute('aria-expanded', String(v));\n      more.setAttribute('aria-label', v ? 'Show fewer' : `Show ${extra} more`);\n      more.firstChild.textContent = v ? '−' : `+${extra}`;\n      people().slice(max).forEach((it, k) => { it.showAt = v ? now + 60 + k * 70 : 0; });\n      kick();\n    }\n    const itemAt = el => { const b = el && el.closest && el.closest('.vs-avatar-stack-item, .vs-avatar-stack-more'); return b && root.contains(b) ? items.findIndex(it => it.el === b) : -1; };\n\n    // mouse\n    root.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { fan = true; kick(); } });\n    root.addEventListener('pointermove', e => { if (e.pointerType === 'mouse') { const i = itemAt(e.target); if (i >= 0) setHover(i); } });\n    root.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !root.contains(document.activeElement)) { fan = false; setHover(-1); } else if (e.pointerType === 'mouse') setHover(-1); });\n    // touch: press, then slide along the stack\n    let touching = false;\n    root.addEventListener('pointerdown', e => {\n      if (e.pointerType === 'mouse') return; touching = true; clearTimeout(hideT); fan = true;\n      root.setPointerCapture(e.pointerId); const i = itemAt(e.target); if (i >= 0) setHover(i); kick();\n    });\n    root.addEventListener('pointermove', e => { if (touching) { const i = itemAt(document.elementFromPoint(e.clientX, e.clientY)); if (i >= 0) setHover(i); } });\n    const tEnd = () => { if (!touching) return; touching = false; hideT = setTimeout(() => { setHover(-1); if (!open) { fan = false; kick(); } }, 1600); };\n    root.addEventListener('pointerup', tEnd); root.addEventListener('pointercancel', tEnd);\n    document.addEventListener('pointerdown', e => { if (!root.contains(e.target) && (fan || open)) { fan = false; setOpen(false); setHover(-1); } });\n    // keyboard focus\n    root.addEventListener('focusin', e => { fan = true; const i = itemAt(e.target); if (i >= 0) setHover(i); });\n    root.addEventListener('focusout', () => setTimeout(() => { if (!root.contains(document.activeElement)) { fan = false; setHover(-1); if (open) setOpen(false); } }, 0));\n    root.addEventListener('keydown', e => {\n      if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; const vis = visible(), k = vis.findIndex(it => it.el === document.activeElement); if (k < 0) return;\n      e.preventDefault(); vis[clamp(k + (e.key === 'ArrowRight' ? 1 : -1), 0, vis.length - 1)].el.focus();\n    });\n    // clicks\n    items.forEach(it => it.el.addEventListener('click', () => {\n      if (it.more) { setOpen(!open); return; }\n      if (!RM.matches) it.el.animate([{ scale: '1' }, { scale: '.86' }, { scale: '1.06' }, { scale: '1' }], { duration: 420, easing: 'cubic-bezier(.2,0,0,1)', composite: 'add' });\n      root.dispatchEvent(new CustomEvent('select', { detail: { name: it.el.dataset.name, index: it.i } }));\n    }));\n\n    new ResizeObserver(() => { read(); kick(); }).observe(root);\n    new MutationObserver(() => { read(); kick(); }).observe(root, { attributes: true, attributeFilter: ['class', 'data-max'] });\n    read(); targets(); for (const it of items) for (const k of ['x', 'y', 's', 'o']) { it[k].v = it[k].t; } render();\n    const api = { fan: v => { fan = v; kick(); }, open: v => setOpen(v ?? !open), hover: i => setHover(i), refresh: () => { read(); kick(); } };\n    root.__vsAvatarStack = api; return api;\n  }\n  document.querySelectorAll('.vs-avatar-stack').forEach(mount);\n  window.vsAvatarStack = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Media",
    "tags": [
      "avatar",
      "avatar group",
      "facepile",
      "tooltip",
      "hover",
      "spring",
      "team",
      "presence"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#3b6cff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/avatar-stack/index.html",
    "thumbnail": "https://vividsites.app/api/component/avatar-stack/thumb.jpg",
    "video": "https://vividsites.app/api/component/avatar-stack/thumb.mp4",
    "page": "https://vividsites.app/c/avatar-stack"
  }
}