Skip to contentVibraUI
Data display

Avatar group

A row of overlapping faces with the overflow named for anyone who cannot see the pictures.

A client component: the overflow chip carries a tooltip. Each avatar is a role="img" named after its person, and the initials behind it are hidden from screen readers so a name is never read twice. The group itself is named "N people" unless you pass your own aria-label. The "+N" chip is a real button, so a keyboard reaches the tooltip, and it is named after the people it hides — the tooltip is the same list for a pointer. Names beyond the initials come from getInitials, so "Ada Lovelace" falls back to AL. Note that the upstream avatar item also exports an AvatarGroup: this one is the Vibra component, imported from @/components/ui/avatar-group. Both roots carry data-slot="avatar-group" as well, so once both are installed a selector on that slot matches either one — narrow it by the surrounding component if you style through it.

Install

npx shadcn@latest add @vibra/avatar-group

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
users{ name: string; src?: string; fallback?: string }[]—The people, in the order they should appear.
maxnumberevery userHow many faces to show before the rest collapse into a "+N" chip.
size"xs" | "sm" | "default" | "lg""default"size-5, size-6, size-8, or size-10, with the initials and the overlap scaled to match.
ringbooleantrueRings each face in the page background, which is what keeps overlapping faces apart.
classNamestring—Merged onto the div root; the remaining div props are spread onto it too.

Dependencies

Source

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

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

import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"

const avatarGroupVariants = cva("flex w-fit items-center", {
  variants: {
    size: {
      xs: "-space-x-1.5",
      sm: "-space-x-2",
      default: "-space-x-2",
      lg: "-space-x-2.5",
    },
    // The ring is what keeps overlapping faces apart; drop it only where the
    // group sits on a background it cannot know.
    ring: {
      true: "*:ring-2 *:ring-background",
      false: "",
    },
  },
  defaultVariants: { size: "default", ring: true },
})

type AvatarGroupSize = NonNullable<VariantProps<typeof avatarGroupVariants>["size"]>

// COUPLED TO registry/vibra/ui/avatar.tsx: Avatar sizes itself with `size-8`
// and its fallback with `text-sm`, and its own data-[size=…] variants beat a
// plain utility on specificity. So the four sizes here are set as flat classes
// that tailwind-merge folds into those base ones, and `size` is left off the
// primitive. If Avatar ever moves its base size into a data-variant, these
// stop winning — avatar-group.test.tsx pins the rendered size classes.
const AVATAR_SIZES: Record<AvatarGroupSize, { root: string; text: string }> = {
  xs: { root: "size-5", text: "text-avatar" },
  sm: { root: "size-6", text: "text-2xs" },
  default: { root: "size-8", text: "text-xs" },
  lg: { root: "size-10", text: "text-sm" },
}

export type AvatarGroupUser = {
  name: string
  src?: string
  /** Replaces the initials taken from `name`. */
  fallback?: string
}

export type AvatarGroupProps = React.ComponentProps<"div"> & {
  users: AvatarGroupUser[]
  /** How many faces to show before the rest collapse into a "+N" chip. */
  max?: number
  size?: AvatarGroupSize
  ring?: boolean
}

/** A row of overlapping faces, with the overflow named for anyone who cannot see the pictures. */
function AvatarGroup({
  className,
  users,
  max,
  size = "default",
  ring = true,
  ...props
}: AvatarGroupProps) {
  if (users.length === 0) return null

  const limit = max === undefined ? users.length : Math.max(0, max)
  const shown = users.slice(0, limit)
  const hidden = users.slice(limit)
  const sizing = AVATAR_SIZES[size]

  return (
    <div
      data-slot="avatar-group"
      data-size={size}
      data-ring={ring || undefined}
      role="group"
      aria-label={`${users.length} ${users.length === 1 ? "person" : "people"}`}
      className={cn(avatarGroupVariants({ size, ring }), className)}
      {...props}
    >
      {shown.map((user, index) => (
        <Avatar
          // Two people can share a name; the position is what makes the key unique.
          key={`${user.name}-${index}`}
          role="img"
          aria-label={user.name}
          className={sizing.root}
        >
          {user.src ? <AvatarImage src={user.src} alt="" /> : null}
          <AvatarFallback aria-hidden="true" className={sizing.text}>
            {user.fallback ?? getInitials(user.name)}
          </AvatarFallback>
        </Avatar>
      ))}

      {hidden.length > 0 ? (
        <Tooltip>
          <TooltipTrigger
            render={
              <button
                type="button"
                data-slot="avatar-group-overflow"
                // The tooltip only reaches a pointer or a focused control, so
                // the names it lists are also the button's own name.
                aria-label={`${hidden.length} more: ${hidden.map((user) => user.name).join(", ")}`}
                className={cn(
                  "relative inline-flex shrink-0 cursor-default items-center justify-center rounded-full bg-muted font-medium text-muted-foreground tabular-nums focus-ring",
                  sizing.root,
                  sizing.text
                )}
              >
                {`+${hidden.length}`}
              </button>
            }
          />
          <TooltipContent>{hidden.map((user) => user.name).join(", ")}</TooltipContent>
        </Tooltip>
      ) : null}
    </div>
  )
}

export { AvatarGroup, avatarGroupVariants }