# Avatar Group

> Overlapping avatar stack with deterministic name-hashed colors, +N overflow chip, sizes and presence status dots.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/avatar-group
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/avatar-group.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/avatar-group-demo.tsx"
import { Avatar, AvatarGroup, type AvatarUser } from "@/components/avatar-group"

const team: AvatarUser[] = [
  { name: "Ada Lovelace", status: "online" },
  { name: "Grace Hopper", status: "away" },
  { name: "Alan Turing", status: "online" },
  { name: "Margaret Hamilton" },
  { name: "Linus Torvalds", status: "offline" },
  { name: "Katherine Johnson" },
  { name: "Dennis Ritchie" },
]

export default function AvatarGroupDemo() {
  return (
    <div className="flex w-full max-w-md flex-col gap-8">
      <section className="grid gap-3">
        <p className="text-sm font-medium text-muted-foreground">Overflow with max=4</p>
        <AvatarGroup users={team} max={4} />
      </section>

      <section className="grid gap-3">
        <p className="text-sm font-medium text-muted-foreground">Sizes</p>
        <div className="grid gap-4">
          <AvatarGroup users={team} max={3} size="sm" />
          <AvatarGroup users={team} max={4} size="md" />
          <AvatarGroup users={team} max={5} size="lg" />
          <AvatarGroup users={team} max={3} size="xl" />
        </div>
      </section>

      <section className="grid gap-3">
        <p className="text-sm font-medium text-muted-foreground">Avatar with status</p>
        <div className="flex items-center gap-4">
          <Avatar name="Ada Lovelace" status="online" size="lg" />
          <Avatar name="Grace Hopper" status="away" size="lg" />
          <Avatar name="Alan Turing" status="offline" size="lg" />
        </div>
      </section>
    </div>
  )
}
```

## Source

```tsx title="components/avatar-group.tsx"
import * as React from "react"

import { cn } from "@/lib/utils"

export type AvatarSize = "sm" | "md" | "lg" | "xl"
export type AvatarStatus = "online" | "away" | "offline"

export interface AvatarUser {
  name: string
  status?: AvatarStatus
}

const sizes: Record<AvatarSize, { box: string; dot: string; overlap: string }> = {
  sm: { box: "size-8 text-xs", dot: "size-2", overlap: "-space-x-2" },
  md: { box: "size-10 text-sm", dot: "size-2.5", overlap: "-space-x-3" },
  lg: { box: "size-12 text-base", dot: "size-3", overlap: "-space-x-3.5" },
  xl: { box: "size-16 text-lg", dot: "size-3.5", overlap: "-space-x-4" },
}

const statusColor: Record<AvatarStatus, string> = {
  online: "bg-emerald-500",
  away: "bg-amber-500",
  offline: "bg-zinc-400 dark:bg-zinc-500",
}

/** Deterministic string hash (djb2) -> unsigned 32-bit integer. */
function hashString(value: string) {
  let hash = 5381
  for (let i = 0; i < value.length; i++) {
    hash = ((hash << 5) + hash + value.charCodeAt(i)) >>> 0
  }
  return hash
}

function initialsOf(name: string) {
  const parts = name.trim().split(/\s+/).filter(Boolean)
  if (parts.length === 0) return "?"
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}

export interface AvatarProps extends React.ComponentProps<"span"> {
  name: string
  size?: AvatarSize
  status?: AvatarStatus
}

export function Avatar({ name, size = "md", status, className, style, ...props }: AvatarProps) {
  const hue = hashString(name) % 360
  const label = status ? `${name} (${status})` : name

  return (
    <span
      role="img"
      aria-label={label}
      title={label}
      className={cn("relative inline-flex shrink-0 select-none", className)}
      {...props}
    >
      <span
        className={cn(
          "inline-flex items-center justify-center rounded-full font-semibold tracking-tight text-white",
          sizes[size].box
        )}
        style={{ backgroundColor: `hsl(${hue} 62% 30%)`, ...style }}
        aria-hidden
      >
        {initialsOf(name)}
      </span>
      {status && (
        <span
          aria-hidden
          className={cn(
            "absolute right-0 bottom-0 rounded-full ring-2 ring-background",
            sizes[size].dot,
            statusColor[status]
          )}
        />
      )}
    </span>
  )
}

export interface AvatarGroupProps extends React.ComponentProps<"div"> {
  users: AvatarUser[]
  /** Maximum avatars shown before collapsing into a "+N" chip */
  max?: number
  size?: AvatarSize
}

export function AvatarGroup({ users, max = 4, size = "md", className, ...props }: AvatarGroupProps) {
  const visible = users.slice(0, Math.max(max, 0))
  const hidden = users.slice(visible.length)
  const hiddenNames = hidden.map((u) => u.name).join(", ")

  return (
    <div
      role="group"
      aria-label={`${users.length} members`}
      className={cn("flex items-center", sizes[size].overlap, className)}
      {...props}
    >
      {visible.map((user) => (
        <Avatar
          key={user.name}
          name={user.name}
          size={size}
          status={user.status}
          className="rounded-full ring-2 ring-background transition-transform duration-200 hover:z-10 hover:-translate-y-1"
        />
      ))}
      {hidden.length > 0 && (
        <span
          role="img"
          aria-label={`${hidden.length} more: ${hiddenNames}`}
          title={hiddenNames}
          className={cn(
            "inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-muted-foreground tabular-nums ring-2 ring-background transition-transform duration-200 hover:z-10 hover:-translate-y-1",
            sizes[size].box
          )}
        >
          +{hidden.length}
        </span>
      )}
    </div>
  )
}
```
