# Avatar Stack (Moonarc)

A row of overlapping avatars with a count chip such as "+2 398". Lazy images or initials, overlapped by a negative margin with a ring in the surface colour. Hover the row and it spreads; hover one and it rises and its name appears above it in a CSS tooltip. Avatars are not tab stops unless they carry an href; the names live in the alt text and the chip has its own screen-reader line. The count is formatted with Intl at build. Zero JavaScript.

- Import: `import AvatarStack from '@moonarc/core/AvatarStack'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/avatar-stack.json`
- Tier A · category ui · trigger hover
- Readout: `<AvatarStack people={people} max={5} count={2403}>`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 6.7 kB raw)
- Page: https://moonarc.dev/components/avatar-stack/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `people` | `{ name, src?, href? }[]` | none | Who to show. src is a photo (lazy, async); without one the initials are drawn. href makes the avatar a link and a tab stop. |
| `max` | `number` | `5` | Avatars shown; the rest are the chip. |
| `count` | `number` | `people.length` | The total; the chip prints count − max. |
| `size` | `string` | `'2.5rem'` | Avatar size; the overlap, the ring and the initials scale with it. |
| `locale` | `string` | `'en'` | Intl.NumberFormat locale for the chip. |
| `moreLabel` | `string` | `'and {n} others'` | What a screen reader gets for the chip; {n} is the formatted number. |
| `ring` | `string` | none | Ring colour; default the panel token. Pass the surface the stack sits on. |

## Usage

```astro
---
const people = [
  { name: 'Ari Santoso', src: '/avatars/ari.jpg' },
  { name: 'Mei Lin', src: '/avatars/mei.jpg' },
  { name: 'Noor Haddad' },
  { name: 'Tomás Reyes', href: '/team/tomas/' },
];
---
<AvatarStack people={people} max={4} count={2403} locale="en" />
<p>Joined by <AvatarStack people={people} max={3} size="1.5rem" /> and the rest of the list.</p>
```

## Reduced motion

The spread happens without a transition and nothing rises; the tooltip still appears.

## With ClientRouter

Static; nothing to bind.

## Craft

- The overlap is a negative inline margin and the ring is a box-shadow in the surface colour, so the stack reads as a stack on any background. Pass ring to match a card. On hover the margin goes positive and the row opens on the preset curve; the first item keeps its margin so the row does not jump sideways.
- The name is a tooltip inside the item (absolute, above, centred with translate), rising from the hover travel token to zero. It is aria-hidden: the accessible name of each avatar is its alt, and a decorative row is not six tab stops.
- With href an avatar is a real link and reaches the keyboard; :focus-visible then does what hover does, through :has() on the item, so the name shows for a keyboard reader as well.
- The whole hover block is behind (hover: hover) and (pointer: fine): on touch the row is still and no tooltip appears, because a tooltip that opens on tap has no way to close.
- The chip is formatted with Intl.NumberFormat at build time (locale), giving "2,398" or "2 398", and carries its own screen-reader sentence from moreLabel.

## Replaces

- AnimatedTooltip (Aceternity)
- AvatarCircles (Magic UI)
- AvatarGroup (shadcn examples)

## Source

```astro
---
/**
 * AvatarStack — overlapping avatars and a "+2 398", zero JS. A list of
 * lazy images (or initials where there is no photo) overlapped through a
 * negative margin, each with a ring in the surface colour so the overlap
 * reads. Hover the list and the row spreads; hover one and it rises and
 * its name appears above it — a CSS tooltip, no popover. Avatars are not
 * focusable: six tab stops for decoration is a keyboard trap, and the
 * names are in the alt text. With `href` an avatar becomes a link, is
 * focusable, and shows its name on :focus-visible as well. Touch gets the
 * still row. The count is formatted at build with Intl (`locale`).
 */
import type { HTMLAttributes } from 'astro/types';

interface Person {
  name: string;
  /** Photo URL; without one, the initials are drawn. */
  src?: string;
  href?: string;
}

interface Props extends HTMLAttributes<'ul'> {
  people: Person[];
  /** Avatars shown; the rest go into the chip. */
  max?: number;
  /** Total count; the chip shows count − max. Defaults to people.length. */
  count?: number;
  /** Avatar size, any CSS length. */
  size?: string;
  /** Intl locale for the chip's number. */
  locale?: string;
  /** Screen-reader text of the chip, with `{n}` for the formatted number. */
  moreLabel?: string;
  /** Ring colour around each avatar; default the panel token. */
  ring?: string;
}

const { people, max = 5, count, size = '2.5rem', locale = 'en', moreLabel = 'and {n} others', ring, class: className, style, ...rest } = Astro.props;
const shown = people.slice(0, Math.max(1, max));
const total = count ?? people.length;
const more = Math.max(0, total - shown.length);
const moreText = new Intl.NumberFormat(locale).format(more);
const initials = (name: string) =>
  name
    .split(/\s+/)
    .map((w) => w[0] ?? '')
    .join('')
    .slice(0, 2)
    .toUpperCase();
const vars = [`--ma-avatars-size:${size}`, ring && `--ma-avatars-ring:${ring}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<ul class:list={['ma-avatars', className]} style={vars} {...rest}>
  {
    shown.map((p) => (
      <li class="ma-avatars__item">
        {p.href ? (
          <a href={p.href} class="ma-avatars__face">
            {p.src ? <img src={p.src} alt={p.name} loading="lazy" decoding="async" /> : <span class="ma-avatars__initials" aria-hidden="true">{initials(p.name)}</span>}
            {!p.src && <span class="ma-avatars__sr">{p.name}</span>}
          </a>
        ) : (
          <span class="ma-avatars__face">
            {p.src ? <img src={p.src} alt={p.name} loading="lazy" decoding="async" /> : <span class="ma-avatars__initials" aria-hidden="true">{initials(p.name)}</span>}
            {!p.src && <span class="ma-avatars__sr">{p.name}</span>}
          </span>
        )}
        <span class="ma-avatars__tip" aria-hidden="true">{p.name}</span>
      </li>
    ))
  }
  {more > 0 && (
    <li class="ma-avatars__item ma-avatars__more">
      <span class="ma-avatars__face" aria-hidden="true">+{moreText}</span>
      <span class="ma-avatars__sr">{moreLabel.replace('{n}', moreText)}</span>
    </li>
  )}
</ul>

<style is:global>
  @layer components {
    :where(.ma-avatars) {
      --ma-avatars-overlap: calc(var(--ma-avatars-size) * -0.26);
      display: flex;
      align-items: center;
      margin: 0;
      padding: 0;
      padding-inline-start: calc(var(--ma-avatars-overlap) * -1);
      list-style: none;
      isolation: isolate;
    }
    :where(.ma-avatars__item) {
      position: relative;
      margin-inline-start: var(--ma-avatars-overlap);
      transition:
        margin var(--ma-duration) var(--ma-ease),
        translate var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-avatars__face) {
      display: grid;
      place-items: center;
      inline-size: var(--ma-avatars-size);
      block-size: var(--ma-avatars-size);
      border-radius: 50%;
      box-shadow: 0 0 0 2px var(--ma-avatars-ring, var(--ma-panel));
      background: color-mix(in srgb, var(--ma-ink) 10%, var(--ma-panel));
      color: inherit;
      overflow: clip;
      text-decoration: none;
      font-size: calc(var(--ma-avatars-size) * 0.3);
      font-weight: 600;
      letter-spacing: -0.02em;
    }
    :where(.ma-avatars__face > img) {
      inline-size: 100%;
      block-size: 100%;
      object-fit: cover;
    }
    :where(.ma-avatars__more .ma-avatars__face) {
      background: var(--ma-ink);
      color: var(--ma-panel);
      font-size: calc(var(--ma-avatars-size) * 0.3);
      font-variant-numeric: tabular-nums;
      /* a count wider than the circle grows into a pill instead of spilling out of it: +2,398. border-box, so a project
         without a reset (content-box) still gets a circle for "+3" */
      box-sizing: border-box;
      inline-size: auto;
      min-inline-size: var(--ma-avatars-size);
      padding-inline: calc(var(--ma-avatars-size) * 0.2);
      border-radius: calc(var(--ma-avatars-size) / 2);
    }
    /* centred on its avatar: left and -50% are the same in both directions, where inset-inline-start would be right: 50%
       on a right-to-left page and push the name a whole width off */
    :where(.ma-avatars__tip) {
      position: absolute;
      inset-block-end: calc(100% + 0.5em);
      left: 50%;
      padding: 0.3em 0.6em;
      border-radius: 0.4em;
      background: var(--ma-ink);
      color: var(--ma-panel);
      font-size: 0.75rem;
      line-height: 1.2;
      white-space: nowrap;
      translate: -50% var(--ma-travel-hover);
      opacity: 0;
      pointer-events: none;
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        translate var(--ma-duration-fast) var(--ma-ease-out);
    }
    /* a pointer that hovers: the row spreads; the one under it rises and names itself. Touch never sees this. */
    @media (hover: hover) and (pointer: fine) {
      :where(.ma-avatars:hover .ma-avatars__item) {
        margin-inline-start: calc(var(--ma-avatars-size) * 0.08);
      }
      :where(.ma-avatars:hover .ma-avatars__item:first-child) {
        margin-inline-start: var(--ma-avatars-overlap);
      }
      :where(.ma-avatars__item:hover) {
        z-index: 1;
        translate: 0 calc(var(--ma-travel-hover) * -1);
      }
      :where(.ma-avatars__item:hover .ma-avatars__tip) {
        opacity: 1;
        translate: -50% 0;
        transition-duration: var(--ma-duration);
        transition-timing-function: var(--ma-ease);
      }
    }
    /* a linked avatar reached from the keyboard names itself the same way */
    :where(.ma-avatars__item:has(.ma-avatars__face:focus-visible)) {
      z-index: 1;
      translate: 0 calc(var(--ma-travel-hover) * -1);
    }
    :where(.ma-avatars__item:has(.ma-avatars__face:focus-visible) .ma-avatars__tip) {
      opacity: 1;
      translate: -50% 0;
    }
    :where(.ma-avatars__face:focus-visible) {
      outline: 2px solid var(--ma-ink);
      outline-offset: 2px;
    }
    :where(.ma-avatars__sr) {
      position: absolute;
      inline-size: 1px;
      block-size: 1px;
      margin: -1px;
      padding: 0;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-avatars__item),
      :where(.ma-avatars__tip) {
        transition: none;
      }
      :where(.ma-avatars__item:hover),
      :where(.ma-avatars__item:has(.ma-avatars__face:focus-visible)) {
        translate: 0 0;
      }
    }
  }
</style>

```
