--primitiv-avatar-group-overlap--primitiv-avatar-group-ring-width--primitiv-avatar-group-ring-colorAn overlapping row of Avatars with an optional "+N" overflow counter — the collaborators/attendees pattern. Composes the registry avatar component; the counter is itself an Avatar, not a Badge. Sized xs–xl; data-density scales each size further.
primitiv add avatar-group installs it whichever mode you are reading.Preview
import { AvatarGroup } from "@/components/ui/avatar-group";import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
<AvatarGroup size="md" direction="ltr"> <Avatar size="md"> <AvatarImage src="/avatar-1.png" alt="" /> <AvatarFallback>AB</AvatarFallback> </Avatar> <Avatar size="md"> <AvatarImage src="/avatar-2.png" alt="" /> <AvatarFallback>CD</AvatarFallback> </Avatar> <Avatar size="md"> <AvatarImage src="/avatar-3.png" alt="" /> <AvatarFallback>EF</AvatarFallback> </Avatar> <Avatar size="md"> <AvatarImage src="/avatar-4.png" alt="" /> <AvatarFallback>GH</AvatarFallback> </Avatar> <Avatar size="md"> <AvatarImage src="/avatar-5.png" alt="" /> <AvatarFallback>JK</AvatarFallback> </Avatar></AvatarGroup>Density is set by a data-density ancestor — the Context system, not a AvatarGroup prop.
npx primitiv add avatar-grouppnpm dlx primitiv add avatar-groupyarn dlx primitiv add avatar-groupbunx primitiv add avatar-groupImport
import { AvatarGroup } from "@/components/ui/avatar-group";No headless primitive — this one ships only as a copied styled surface, so primitiv add is the only way in, whichever mode you are reading.
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | The Avatar elements. |
| max | number | — | Show at most this many faces, replacing the remainder with a +N
counter. Omit to show every child and no counter. |
| overflowLabel | (count: number) => string | (n) => `${n} more` | Accessible label for the overflow counter, given the count. The counter is decorative-by-default otherwise. |
| size | "xs" | "sm" | "md" | "lg" | "xl" | md | Matches the nested Avatars' size; data-density scales each size further. |
| direction | "ltr" | "rtl" | ltr | Which way the stack advances. Decides which end the counter lands on. |
--primitiv-avatar-group-overlap--primitiv-avatar-group-ring-width--primitiv-avatar-group-ring-color+N avatar is decorative by default; pass overflowLabel so it announces something a person can use ("3 more people", not "+3"). Everything else in the group is your Avatars, whose names you control there.alt="" — a screen reader then skips the images rather than reading a filename or a duplicate name. If a face must be named, put the name on that Avatar (an alt, or an aria-label on its Root), not on the group.<div> with no list semantics — it is a visual arrangement, not a list/listitem structure. If the collection is meaningful as a list to assistive technology, wrap it in your own labelled <ul>/<li> around the avatars.size and direction are visual. direction mirrors the layout for RTL but changes nothing about reading order in the accessibility tree; do not use it to convey meaning.Avatar in your own Tooltip when you need names — and remember a tooltip needs a focusable trigger, so those avatars must become buttons or links.--primitiv-avatar-group-ring-color must match the real background for the cutout illusion to hold; a mismatched ring reads as a coloured outline, which is a visual bug rather than an accessibility one but is worth catching in the same pass.A row of Avatars the group overlaps and rings. The faces are your own elements — the group lays them out but cannot set their size, so pass the same size to the group and to each Avatar. Stacking counts into the row: the first face paints on top of the second, and so on. The ring between them is drawn in the surface colour so it reads as a cutout, not a border.
import { AvatarGroup } from "@/components/ui/avatar-group";import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
<AvatarGroup> <Avatar> <AvatarImage src="/avatar-1.png" alt="" /> <AvatarFallback>AB</AvatarFallback> </Avatar> <Avatar> <AvatarImage src="/avatar-2.png" alt="" /> <AvatarFallback>CD</AvatarFallback> </Avatar> <Avatar> <AvatarImage src="/avatar-3.png" alt="" /> <AvatarFallback>EF</AvatarFallback> </Avatar> <Avatar> <AvatarImage src="/avatar-4.png" alt="" /> <AvatarFallback>GH</AvatarFallback> </Avatar></AvatarGroup>max shows at most that many faces and folds the rest into a +N counter — the counter is itself an Avatar, not a Badge, so it inherits the ring, size, shape and radius for free (Badge has no neutral tone, and a status colour on an overflow count would be a lie). Give it a real accessible name with overflowLabel, since "+3" alone tells a screen-reader user nothing.
import { AvatarGroup } from "@/components/ui/avatar-group";import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
// members: { id, avatar, initials }[] — 7 people here<AvatarGroup max={4} overflowLabel={(n) => `${n} more people`}> {members.map((m) => ( <Avatar key={m.id}> <AvatarImage src={m.avatar} alt="" /> <AvatarFallback>{m.initials}</AvatarFallback> </Avatar> ))}</AvatarGroup>Five sizes, each rescaling again with the nearest data-density ancestor — the same framed-control/* scale the Avatar itself uses. size sets the overlap and ring width; the reminder bears repeating because it is the one real gotcha here: the group cannot size its children, so size goes on the group and every Avatar inside it.
import { AvatarGroup } from "@/components/ui/avatar-group";import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
{["xs", "sm", "md", "lg", "xl"].map((size) => ( <AvatarGroup key={size} size={size}> {members.map((m) => ( <Avatar key={m.id} size={size}> <AvatarImage src={m.avatar} alt="" /> <AvatarFallback>{m.initials}</AvatarFallback> </Avatar> ))} </AvatarGroup>))}direction decides which way the stack advances and, with max, which end the counter lands on. "ltr" (the default) advances rightward with the counter trailing on the right; "rtl" mirrors it for a right-to-left locale. Set it to match the reading direction of the surrounding content rather than hand-reordering the children.
import { AvatarGroup } from "@/components/ui/avatar-group";import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
<AvatarGroup direction="rtl" max={4}> {members.map((m) => ( <Avatar key={m.id}> <AvatarImage src={m.avatar} alt="" /> <AvatarFallback>{m.initials}</AvatarFallback> </Avatar> ))}</AvatarGroup>The separating ring is the surface colour by default, so it reads as a cutout on a default surface. On any other background it will be wrong by construction — set --primitiv-avatar-group-ring-color to that background's colour, on the group itself (the component re-declares its own default, which shadows a value merely inherited from an ancestor).
import { AvatarGroup } from "@/components/ui/avatar-group";import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
<div style={{ background: "var(--primitiv-surface-sunken)", padding: "1rem" }}> <AvatarGroup style={{ "--primitiv-avatar-group-ring-color": "var(--primitiv-surface-sunken)" }} > <Avatar> <AvatarImage src="/avatar-1.png" alt="" /> <AvatarFallback>AB</AvatarFallback> </Avatar> <Avatar> <AvatarImage src="/avatar-2.png" alt="" /> <AvatarFallback>CD</AvatarFallback> </Avatar> <Avatar> <AvatarImage src="/avatar-3.png" alt="" /> <AvatarFallback>EF</AvatarFallback> </Avatar> <Avatar> <AvatarImage src="/avatar-4.png" alt="" /> <AvatarFallback>GH</AvatarFallback> </Avatar> </AvatarGroup></div>