Skip to content

Components / UI

Avatar Stack

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.

Live demo

  • Ari Santoso
  • Mei Lin
  • Noor Haddad
  • Tomás Reyes
  • Lena Okafor
  • Kai Nakamura
  • and 2,398 others

hover the row · hover one for the name

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import AvatarStack from '@moonarc/core/AvatarStack';
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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/avatar-stack.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

AvatarStack.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>

Props

PropTypeDefaultDescription
people{ name, src?, href? }[]noneWho to show. src is a photo (lazy, async); without one the initials are drawn. href makes the avatar a link and a tab stop.
maxnumber5Avatars shown; the rest are the chip.
countnumberpeople.lengthThe total; the chip prints count − max.
sizestring'2.5rem'Avatar size; the overlap, the ring and the initials scale with it.
localestring'en'Intl.NumberFormat locale for the chip.
moreLabelstring'and {n} others'What a screen reader gets for the chip; {n} is the formatted number.
ringstringnoneRing colour; default the panel token. Pass the surface the stack sits on.

Reduced motion

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

With ClientRouter

Static; nothing to bind.

Why it is built this way

Replaces: AnimatedTooltip (Aceternity) · AvatarCircles (Magic UI) · AvatarGroup (shadcn examples). See the migration table.

A link that shows a small card (image, title, one line, domain) after a moment on hover or focus, above everything, flipping when there is no room.

newly · Chrome 125 · Firefox 147 · Safari 26hover

Tilt

Pointer

A card leans toward the cursor in 3D, lifts slightly, catches a glare, and settles back on the active preset's spring when the cursor leaves.

every browserpointerhover

Shows team members in a hairline grid: portraits that tilt toward the cursor with a glare, initials when there is no photo, names and roles revealed with a stagger..

every browserscrollpointer

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown