# Avatar Group

A stacked avatar row with overflow counting and tooltips.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/avatar-group.json
```

[Registry JSON](https://mwui.vercel.app/r/avatar-group.json)

## Preview

```tsx
import { AvatarGroup } from "@/components/ui/avatar-group";

const PEOPLE = [
  { id: "alice", name: "Alice Nguyen" },
  { id: "bruno", name: "Bruno Costa" },
  { id: "chen", name: "Chen Wei" },
  { id: "dara", name: "Dara Okafor" },
  { id: "elif", name: "Elif Demir" },
  { id: "farid", name: "Farid Haddad" },
  { id: "gita", name: "Gita Rao" },
];

export function Preview() {
  return (
    <div className="flex flex-col gap-6 text-sm">
      <div className="flex flex-col gap-2">
        <p className="font-medium">Default</p>
        <AvatarGroup people={PEOPLE} max={4} aria-label="Project members" />
      </div>

      <div className="flex flex-col gap-2">
        <p className="font-medium">Small, more visible</p>
        <AvatarGroup people={PEOPLE} size="sm" max={6} aria-label="Reviewers" />
      </div>

      <div className="flex flex-col gap-2">
        <p className="font-medium">Large, no overflow</p>
        <AvatarGroup people={PEOPLE.slice(0, 3)} size="lg" aria-label="Owners" />
      </div>
    </div>
  );
}
```


## Source

### ui/avatar-group.tsx

```tsx
"use client";

import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";

import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

const avatarVariants = cva(
  // The ring punches a gap between overlapping avatars, so it has to match whatever
  // surface sits behind them. Override with [--avatar-ring:var(--card)] on the group.
  "relative flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground ring-2 ring-[var(--avatar-ring,var(--background))] select-none",
  {
    variants: {
      size: {
        xs: "size-5 text-[0.625rem]",
        sm: "size-6 text-[0.6875rem]",
        default: "size-8 text-xs",
        lg: "size-10 text-sm",
      },
    },
    defaultVariants: {
      size: "default",
    },
  },
);

const overlapBySize = {
  xs: "-ml-1.5",
  sm: "-ml-2",
  default: "-ml-2.5",
  lg: "-ml-3",
} as const;

type AvatarSize = NonNullable<VariantProps<typeof avatarVariants>["size"]>;

type AvatarGroupPerson = {
  id?: string;
  name: string;
  src?: string;
  initials?: string;
  href?: string;
};

type AvatarProps = React.ComponentProps<"span"> &
  VariantProps<typeof avatarVariants> & {
    name: string;
    src?: string;
    initials?: string;
  };

function Avatar({ name, src, initials, size, className, ...props }: AvatarProps) {
  const [failed, setFailed] = React.useState(false);
  const showImage = src !== undefined && !failed;

  return (
    <span
      data-slot="avatar"
      className={cn(avatarVariants({ size }), className)}
      title={props.title ?? undefined}
      {...props}
    >
      {showImage ? (
        <img
          src={src}
          alt={name}
          loading="lazy"
          className="size-full object-cover"
          onError={() => setFailed(true)}
        />
      ) : (
        <span aria-hidden="true">{initials ?? getInitials(name)}</span>
      )}
      {showImage ? null : <span className="sr-only">{name}</span>}
    </span>
  );
}

type AvatarGroupProps = React.ComponentProps<"div"> &
  VariantProps<typeof avatarVariants> & {
    people: AvatarGroupPerson[];
    max?: number;
    tooltips?: boolean;
    "aria-label"?: string;
  };

function AvatarGroup({
  people,
  max = 4,
  size = "default",
  tooltips = true,
  className,
  "aria-label": ariaLabel = "People",
  ...props
}: AvatarGroupProps) {
  const visible = people.slice(0, max);
  const overflow = people.slice(max);
  const overlap = overlapBySize[(size ?? "default") as AvatarSize];

  return (
    <div
      data-slot="avatar-group"
      role="group"
      aria-label={ariaLabel}
      className={cn("flex items-center", className)}
      {...props}
    >
      {visible.map((person, index) => (
        <AvatarGroupMember
          key={person.id ?? person.name}
          person={person}
          size={size}
          tooltips={tooltips}
          className={index === 0 ? undefined : overlap}
        />
      ))}

      {overflow.length > 0 ? (
        <OptionalTooltip
          enabled={tooltips}
          label={overflow.map((person) => person.name).join(", ")}
        >
          <span
            data-slot="avatar-group-overflow"
            className={cn(avatarVariants({ size }), overlap, "bg-muted text-muted-foreground")}
          >
            <span aria-hidden="true">+{overflow.length}</span>
            <span className="sr-only">{overflow.length} more</span>
          </span>
        </OptionalTooltip>
      ) : null}
    </div>
  );
}

function AvatarGroupMember({
  person,
  size,
  tooltips,
  className,
}: {
  person: AvatarGroupPerson;
  size: VariantProps<typeof avatarVariants>["size"];
  tooltips: boolean;
  className?: string;
}) {
  const avatar = (
    <Avatar
      name={person.name}
      src={person.src}
      initials={person.initials}
      size={size}
      className={cn("transition-transform hover:z-10 hover:-translate-y-0.5", className)}
    />
  );

  if (person.href) {
    return (
      <OptionalTooltip enabled={tooltips} label={person.name}>
        <a
          href={person.href}
          className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
        >
          {avatar}
        </a>
      </OptionalTooltip>
    );
  }

  return (
    <OptionalTooltip enabled={tooltips} label={person.name}>
      {avatar}
    </OptionalTooltip>
  );
}

function OptionalTooltip({
  enabled,
  label,
  children,
}: {
  enabled: boolean;
  label: string;
  children: React.ReactElement;
}) {
  if (!enabled) return children;

  return (
    <Tooltip>
      <TooltipTrigger render={children} />
      <TooltipContent>{label}</TooltipContent>
    </Tooltip>
  );
}

function getInitials(name: string): string {
  const parts = name.trim().split(/\s+/u).filter(Boolean);

  if (parts.length === 0) return "?";
  if (parts.length === 1) return (parts[0] ?? "").slice(0, 2).toUpperCase();

  return `${(parts[0] ?? "").charAt(0)}${(parts[parts.length - 1] ?? "").charAt(0)}`.toUpperCase();
}

export {
  Avatar,
  AvatarGroup,
  avatarVariants,
  type AvatarGroupPerson,
  type AvatarGroupProps,
  type AvatarProps,
};
```



## Usage

The row of overlapping faces that shows who is on a project, in a thread, or assigned to a task —
with the "+3" that keeps it from taking over the layout.

```tsx
import { AvatarGroup } from "@/components/ui/avatar-group";

const PEOPLE = [
  { id: "1", name: "Alice Nguyen", src: "/avatars/alice.jpg" },
  { id: "2", name: "Bruno Costa" },
  { id: "3", name: "Chen Wei", src: "/avatars/chen.jpg" },
];

<AvatarGroup people={PEOPLE} max={4} />;
```

Self-contained — no separate avatar dependency to install. `Avatar` is exported too, for the places
you need a single one.

## Images and initials

`src` is optional. Without it — or when the image fails to load, which is the common case with
user-supplied URLs — the avatar falls back to initials derived from `name`. Pass `initials` to
override, which matters for names the two-word heuristic gets wrong.

## Overflow

`max` counts avatars, not people: with `max={4}` and six people you see four faces and a `+2`. The
counter's tooltip lists everyone it hides, so the information is still reachable.

## Sizes

`xs`, `sm`, `default`, and `lg`. The overlap scales with the size, so the stack keeps its proportions.

```tsx
<AvatarGroup people={PEOPLE} size="xs" max={6} />
```

## Ring color

Overlapping avatars are separated by a ring that punches a gap in the surface behind them, so it has
to match that surface. It defaults to `var(--background)`, which is right on the page but wrong
anywhere the background differs — inside a `Card`, a popover, or a muted panel, where the ring reads
as a dark outline in dark mode.

Set `--avatar-ring` on any ancestor to correct it. Custom properties inherit, so one declaration
covers every avatar inside, including standalone `Avatar` elements:

```tsx
<Card className="[--avatar-ring:var(--card)]">
  <AvatarGroup people={PEOPLE} />
</Card>
```

In the default theme the light palette gives `--background` and `--card` the same value, so a missing
override only shows up in dark mode. Check both.

## Props

| Prop         | Description                                                            |
| ------------ | ---------------------------------------------------------------------- |
| `people`     | `{ id?, name, src?, initials?, href? }[]`. `href` makes the avatar a link. |
| `max`        | Avatars shown before collapsing. Defaults to `4`.                       |
| `size`       | `"xs"`, `"sm"`, `"default"`, `"lg"`.                                     |
| `tooltips`   | Name on hover and focus. On by default.                                  |
| `aria-label` | Labels the group. Defaults to `"People"`.                                |

## Accessibility

Names are not decoration: an avatar rendering an image uses the name as its `alt`, and an avatar
rendering initials keeps the full name in visually hidden text — so the group reads as a list of
people rather than "image, image, image". The overflow counter announces "3 more".

Tooltips only appear on hover and focus, so the name must also exist in the accessible name for
touch users. That is why both are present.

