Skip to content
Identity primitive

Avatar Group

Overlapping faces with a descending stack order, deterministic colours for initials, and an overflow chip announced as "and 3 more" rather than an ambiguous +3.

148 linesNo dependenciesAdded 13 Sept 2026
  • avatar
  • group
  • stack
  • team
  • members
  • initials

What's included

  • components/ui/avatar-group.tsx
  • No runtime dependencies

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add avatar-group

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Source

One file. Create components/ui/avatar-group.tsx and paste.

components/ui/avatar-group.tsx
'use client'

/**
 * <AvatarGroup> — overlapping faces with an honest overflow count.
 *
 * Two things are usually wrong with this component.
 *
 * The first is the stacking order. Avatars overlap, and the default paint
 * order puts each one on top of the last — so the stack reads
 * right-to-left, the overflow chip sits *under* the avatar before it, and
 * the ring around the first face is clipped. Fixing it means an explicit
 * descending `z-index`, which also has to survive `isolation` so it does
 * not fight the page's own stacking contexts.
 *
 * The second is the count. `+3` next to five avatars is ambiguous — three
 * more, or three total? It is announced here as "and 3 more", and the
 * group's accessible name is the whole list, so a screen reader user hears
 * who is actually on the team rather than "image image image".
 *
 * Initials, not a broken image icon, are the fallback. A missing avatar is
 * the normal case in any product where people have not uploaded one, and
 * the initials are derived from the same name that is already required for
 * the alt text.
 */

import * as React from 'react'

export interface AvatarPerson {
  name: string
  src?: string
}

export interface AvatarGroupProps {
  people: AvatarPerson[]
  /** How many faces before the rest collapse into a count. */
  max?: number
  size?: 'sm' | 'md' | 'lg'
  /** Names the group: "Project members". */
  label?: string
  className?: string
}

const SIZES = {
  sm: { box: 'h-6 w-6 text-[10px]', ring: 'ring-2', overlap: '-ms-2' },
  md: { box: 'h-8 w-8 text-xs', ring: 'ring-2', overlap: '-ms-2.5' },
  lg: { box: 'h-11 w-11 text-sm', ring: 'ring-[3px]', overlap: '-ms-3' },
} as const

/** "Ada Lovelace" → "AL"; "cher" → "C". */
function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean)
  if (parts.length === 0) return '?'
  if (parts.length === 1) return parts[0][0].toUpperCase()
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}

/**
 * A stable colour per person, from the name.
 *
 * Deterministic rather than random: the same person is the same colour on
 * every page and after every reload, which is most of what makes initials
 * readable as identity at all.
 */
function tint(name: string): string {
  const palette = [
    'bg-rose-500/15 text-rose-700 dark:text-rose-300',
    'bg-amber-500/15 text-amber-700 dark:text-amber-300',
    'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
    'bg-sky-500/15 text-sky-700 dark:text-sky-300',
    'bg-violet-500/15 text-violet-700 dark:text-violet-300',
    'bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300',
  ]
  let hash = 0
  for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0
  return palette[hash % palette.length]
}

export function AvatarGroup({
  people,
  max = 4,
  size = 'md',
  label = 'Members',
  className = '',
}: AvatarGroupProps) {
  const shown = people.slice(0, max)
  const overflow = people.length - shown.length
  const s = SIZES[size]

  return (
    <div
      role="group"
      // The whole list, not "4 images". This is the accessible content of
      // the component; everything inside is hidden from the tree.
      aria-label={`${label}: ${people.map((p) => p.name).join(', ')}`}
      // `isolation` so the descending z-indexes below resolve against this
      // group and cannot be reordered by a stacking context on the page.
      className={`isolate flex items-center ${className}`}
    >
      {shown.map((person, i) => (
        <span
          key={`${person.name}-${i}`}
          aria-hidden
          title={person.name}
          // Descending, so each avatar paints OVER the one after it and
          // the stack reads from the first face.
          style={{ zIndex: shown.length - i }}
          className={[
            'relative inline-flex shrink-0 items-center justify-center overflow-hidden',
            'rounded-full font-semibold ring-background',
            s.box,
            s.ring,
            i === 0 ? '' : s.overlap,
            person.src ? 'bg-muted' : tint(person.name),
          ]
            .filter(Boolean)
            .join(' ')}
        >
          {person.src ? (
            /* A plain <img>, not next/image: a primitive has to paste
               into a Vite, CRA or Remix project unchanged, and the alt is
               empty because the group's own label already names everyone
               in it. */
            <img src={person.src} alt="" className="h-full w-full object-cover" />
          ) : (
            initials(person.name)
          )}
        </span>
      ))}

      {overflow > 0 ? (
        <span
          aria-hidden
          style={{ zIndex: 0 }}
          className={[
            'relative inline-flex shrink-0 items-center justify-center rounded-full',
            'bg-muted font-semibold text-muted-foreground ring-background',
            s.box,
            s.ring,
            s.overlap,
          ].join(' ')}
        >
          +{overflow}
        </span>
      ) : null}
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
peoplerequiredAvatarPerson[]—
maxHow many faces before the rest collapse into a count.number4
size'sm' | 'md' | 'lg''md'
labelNames the group: "Project members".string'Members'
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More identity

View all
Open the full page for this primitive

Featured Icon

The glyph-in-a-tinted-box that every feature grid and empty state is built from, in five tones and four treatments — decorative by default, because it sits next to a heading that says the same thing.

Identity124 linesNo deps