Avatar group
Overlapping avatars with a +N counter for the rest. Render every member; max is clamped from 1 to 20.
server - 0 KB
Avatar group
server - 0 KBOverlapping avatars with a +N counter for the rest. Render every member; max is clamped from 1 to 20.
Try it
max
size
The code
avatar-group.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
<AvatarGroup aria-label="Project team">
{TEAM.map(([initials, name]) => (
<AvatarRoot key={initials} size={props.size} role="img" aria-label={name}>
<AvatarFallback>{initials}</AvatarFallback>
</AvatarRoot>
))}
</AvatarGroup>max counts children, so render every member and let the group hide the overflow.
Every state
1 stateTwo
Four
Overflow
Import from the package
topclassui/avatar-group
import { AvatarGroup } from 'topclassui/avatar-group'Install once:npm i topclassui@beta zod
Examples
- The first
maxchildren show, and a last circle says +N.sizereaches that circle only, so give each avatar the samesize. - One avatar per child: an array or a
map()counts one by one, but a fragment counts as one child, so pass the array itself. - A count too long for a circle widens the counter into a pill, so +12,345 keeps its plus and every digit.
- The ring between avatars is the page colour. On a Card, in a Table or on any other surface, give it that surface:
className="*:ring-surface-raised". - A group wider than its container wraps onto another row; in a table cell it keeps one row, so keep
maxsmall there. - Wrap an avatar in a link made
inline-flexand give the link a focus ring: the group rounds it and lifts the focused face.
max
max.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
/** The docs sample: max counts children, so three children under max 2 leave one counted. */
export default function Demo() {
return (
<AvatarGroup max={2} aria-label="Assignees">
<AvatarRoot role="img" aria-label="Omar Abed">
<AvatarFallback>OA</AvatarFallback>
</AvatarRoot>
<AvatarRoot role="img" aria-label="Lina Haddad">
<AvatarFallback>LH</AvatarFallback>
</AvatarRoot>
<AvatarRoot role="img" aria-label="Sara Khalil">
<AvatarFallback>SK</AvatarFallback>
</AvatarRoot>
</AvatarGroup>
)
}
two
two.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
/** Two members fit under the default max of four, so there is no counter. */
export default function Demo() {
return (
<AvatarGroup aria-label="Reviewers">
<AvatarRoot role="img" aria-label="Omar Abed">
<AvatarFallback>OA</AvatarFallback>
</AvatarRoot>
<AvatarRoot role="img" aria-label="Lina Haddad">
<AvatarFallback>LH</AvatarFallback>
</AvatarRoot>
</AvatarGroup>
)
}
four
four.tsx
import { AvatarFallback, AvatarImage, AvatarRoot } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
/** Inline, so the demo never reaches the network. */
const PORTRAIT = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' fill='%23475569'/%3E%3Ccircle cx='20' cy='15' r='7' fill='%23e2e8f0'/%3E%3Cpath d='M6 40a14 14 0 0 1 28 0z' fill='%23e2e8f0'/%3E%3C/svg%3E"
/** Exactly max: all four show and nothing is counted. A picture is named by its alt,
* and initials alone by role and aria-label on the root. */
export default function Demo() {
return (
<AvatarGroup aria-label="Design team">
<AvatarRoot>
<AvatarImage src={PORTRAIT} alt="Omar Abed" />
<AvatarFallback>OA</AvatarFallback>
</AvatarRoot>
<AvatarRoot role="img" aria-label="Lina Haddad">
<AvatarFallback>LH</AvatarFallback>
</AvatarRoot>
<AvatarRoot>
<AvatarImage src={PORTRAIT} alt="Sara Khalil" />
<AvatarFallback>SK</AvatarFallback>
</AvatarRoot>
<AvatarRoot role="img" aria-label="Mona Nasser">
<AvatarFallback>MN</AvatarFallback>
</AvatarRoot>
</AvatarGroup>
)
}
overflow
overflow.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
const TEAM = [
['OA', 'Omar Abed'], ['LH', 'Lina Haddad'], ['SK', 'Sara Khalil'], ['MN', 'Mona Nasser'],
['RT', 'Rami Tahan'], ['YB', 'Yara Bakri'], ['DF', 'Dana Fares'], ['HA', 'Hadi Aziz'],
['NS', 'Nour Salem'], ['KM', 'Karim Masri'], ['ZA', 'Zain Atallah'], ['TJ', 'Tala Jaber'],
['FE', 'Fadi Eid'],
] as const
/** Thirteen members: render every one, and the group shows four and counts nine. */
export default function Demo() {
return (
<AvatarGroup aria-label="Everyone on the project">
{TEAM.map(([initials, name]) => (
<AvatarRoot key={initials} role="img" aria-label={name}>
<AvatarFallback>{initials}</AvatarFallback>
</AvatarRoot>
))}
</AvatarGroup>
)
}
counts
counts.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import type { AvatarSize } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const
const COUNTS = [99, 999, 12_345] as const
/** One face that shows, and then as many members as the counter has to stand for. */
const team = (size: AvatarSize, hidden: number) => Array.from({ length: hidden + 1 }, (_, index) => (
<AvatarRoot key={index} size={size} aria-label={`Member ${String(index + 1)}`}>
<AvatarFallback>M</AvatarFallback>
</AvatarRoot>
))
/** The counter is a circle until the count is too long for one, and then it widens into a
* pill, so +12,345 keeps its plus and every digit at any size. */
export default function Demo() {
return (
<div className="flex flex-col items-start gap-3">
{SIZES.map((size) => (
<div key={size} className="flex flex-wrap items-center gap-3">
{COUNTS.map((count) => (
<AvatarGroup key={count} size={size} max={1} aria-label={`${size}: ${String(count)} more`}>
{team(size, count)}
</AvatarGroup>
))}
</div>
))}
</div>
)
}
sizes
sizes.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
const ROWS = [
['xs', 'Extra small'],
['sm', 'Small'],
['md', 'Medium'],
['lg', 'Large'],
['xl', 'Extra large'],
] as const
const TEAM = [
['OA', 'Omar Abed'], ['LH', 'Lina Haddad'], ['SK', 'Sara Khalil'], ['MN', 'Mona Nasser'],
['RT', 'Rami Tahan'],
] as const
/** The group hands its size to the counter only, so each avatar is given the same one. */
export default function Demo() {
return (
<div className="flex flex-col items-start gap-4">
{ROWS.map(([size, label]) => (
<AvatarGroup key={size} size={size} max={3} aria-label={label}>
{TEAM.map(([initials, name]) => (
<AvatarRoot key={initials} size={size} role="img" aria-label={name}>
<AvatarFallback>{initials}</AvatarFallback>
</AvatarRoot>
))}
</AvatarGroup>
))}
</div>
)
}
linked
linked.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { FOCUS_RING } from 'topclassui/classes'
import { cn } from 'topclassui/cn'
import { AvatarGroup } from 'topclassui/avatar-group'
const TEAM = [
['OA', 'Omar Abed', '/people/omar'], ['LH', 'Lina Haddad', '/people/lina'],
['SK', 'Sara Khalil', '/people/sara'], ['MN', 'Mona Nasser', '/people/mona'],
['RT', 'Rami Tahan', '/people/rami'],
] as const
/** inline-flex makes the link exactly as big as its avatar, and the focus ring is the
* library's own: the group rounds it into a circle and lifts the focused face over the next. */
const link = cn('inline-flex', FOCUS_RING)
/** Each face is a link to its person. The link is the group's child, so it takes the ring
* and the overlap. */
export default function Demo() {
return (
<AvatarGroup size="lg" max={3} aria-label="Maintainers">
{TEAM.map(([initials, name, href]) => (
<a key={initials} href={href} className={link}>
<AvatarRoot size="lg" aria-label={name}>
<AvatarFallback>{initials}</AvatarFallback>
</AvatarRoot>
</a>
))}
</AvatarGroup>
)
}
on-card
Design team
on-card.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { CardBody, CardHeader, CardRoot } from 'topclassui/card'
import { AvatarGroup } from 'topclassui/avatar-group'
const TEAM = [
['OA', 'Omar Abed'], ['LH', 'Lina Haddad'], ['SK', 'Sara Khalil'], ['MN', 'Mona Nasser'],
['RT', 'Rami Tahan'], ['YB', 'Yara Bakri'],
] as const
/** The ring is the page colour, and a Card is a raised surface: in dark mode the two differ,
* so on a Card the ring is given the Card's own colour. */
export default function Demo() {
return (
<div className="max-w-xs">
<CardRoot>
<CardHeader>
<h2>Design team</h2>
</CardHeader>
<CardBody>
<AvatarGroup className="*:ring-surface-raised" aria-label="Design team">
{TEAM.map(([initials, name]) => (
<AvatarRoot key={initials} aria-label={name}>
<AvatarFallback>{initials}</AvatarFallback>
</AvatarRoot>
))}
</AvatarGroup>
</CardBody>
</CardRoot>
</div>
)
}
in-table
in-table.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { Table } from 'topclassui/table'
import { AvatarGroup } from 'topclassui/avatar-group'
const INITIALS = ['OA', 'LH', 'SK', 'MN', 'RT'] as const
interface Project { id: string; name: string; members: number; due: string }
const PROJECTS: Project[] = [
{ id: 'billing', name: 'Billing redesign', members: 3, due: '12 Mar 2026' },
{ id: 'onboarding', name: 'Mobile onboarding', members: 5, due: '4 Apr 2026' },
{ id: 'search', name: 'Search migration', members: 20, due: '30 Jun 2026' },
]
/** Every member is rendered, and the group shows four and counts the rest. The ring takes
* the colour of the table, which is a raised surface as a Card is. */
const team = (project: Project, placed: string) => (
<AvatarGroup className="*:ring-surface-raised" aria-label={`${project.name}: ${placed}`}>
{Array.from({ length: project.members }, (_, index) => (
<AvatarRoot key={index} aria-label={`Member ${String(index + 1)}`}>
<AvatarFallback>{INITIALS[index % INITIALS.length] ?? 'OA'}</AvatarFallback>
</AvatarRoot>
))}
</AvatarGroup>
)
/** A team column, three ways: alone in its cell, beside a count and in a box of its own.
* Each keeps one row at its full width, and on a phone the table scrolls instead. */
export default function Demo() {
return (
<Table
caption="Projects"
items={PROJECTS}
getKey={(project) => project.id}
columns={[
{ key: 'name', header: 'Project', rowHeader: true, cell: (project) => project.name },
{ key: 'team', header: 'Team', cell: (project) => team(project, 'team') },
{
key: 'counted',
header: 'With a count',
cell: (project) => (
<span className="inline-flex items-center gap-2">
{team(project, 'team with a count')}
<span>{project.members} people</span>
</span>
),
},
{
key: 'boxed',
header: 'In a box',
cell: (project) => (
<div className="w-fit rounded-box border border-line p-1">{team(project, 'team in a box')}</div>
),
},
{ key: 'due', header: 'Due', cell: (project) => project.due },
]}
/>
)
}
many
many.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
const INITIALS = ['OA', 'LH', 'SK', 'MN', 'RT', 'YB', 'DF', 'HA', 'NS', 'KM'] as const
/** Sixty people, each with a name of their own. */
const TEAM = Array.from({ length: 60 }, (_, index) => ({
initials: INITIALS[index % INITIALS.length] ?? 'OA',
name: `Member ${String(index + 1)}`,
}))
/** max at its bound of twenty: one row where it fits, and on a phone the row wraps onto
* the next line instead of pushing the page sideways. */
export default function Demo() {
return (
<AvatarGroup max={20} aria-label="Everyone in the company">
{TEAM.map(({ initials, name }) => (
<AvatarRoot key={name} aria-label={name}>
<AvatarFallback>{initials}</AvatarFallback>
</AvatarRoot>
))}
</AvatarGroup>
)
}
custom-size
custom-size.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { AvatarGroup } from 'topclassui/avatar-group'
/** custom emits no size, so one class on the group sizes every child and the counter alike. */
export default function Demo() {
return (
<AvatarGroup size="custom" max={2} className="*:size-16 *:text-lg" aria-label="Founders">
<AvatarRoot size="custom" role="img" aria-label="Omar Abed">
<AvatarFallback>OA</AvatarFallback>
</AvatarRoot>
<AvatarRoot size="custom" role="img" aria-label="Lina Haddad">
<AvatarFallback>LH</AvatarFallback>
</AvatarRoot>
<AvatarRoot size="custom" role="img" aria-label="Sara Khalil">
<AvatarFallback>SK</AvatarFallback>
</AvatarRoot>
<AvatarRoot size="custom" role="img" aria-label="Mona Nasser">
<AvatarFallback>MN</AvatarFallback>
</AvatarRoot>
</AvatarGroup>
)
}
In Arabic, right to left
rtl.tsx
import { AvatarFallback, AvatarRoot } from 'topclassui/avatar'
import { ar } from 'topclassui/labels/ar'
import { AvatarGroup } from 'topclassui/avatar-group'
/** One letter each: two Arabic initials are wider than the part of a circle an overlap leaves. */
const TEAM = [
['ع', 'عمر فتحي'], ['ل', 'لينا حداد'], ['س', 'سارة خليل'], ['م', 'منى ناصر'],
['ر', 'رامي طحان'], ['ي', 'يارا بكري'], ['د', 'دانا فارس'],
] as const
/** The row starts on the right and overlaps toward it; the counter is named in Arabic. */
export default function Demo() {
return (
<div dir="rtl" lang="ar" className="flex flex-col items-start gap-4">
<AvatarGroup labels={ar} locale="ar" aria-label="فريق التصميم">
{TEAM.map(([initials, name]) => (
<AvatarRoot key={initials} role="img" aria-label={name}>
<AvatarFallback>{initials}</AvatarFallback>
</AvatarRoot>
))}
</AvatarGroup>
<AvatarGroup labels={ar} locale="ar" aria-label="المراجعون">
{TEAM.slice(0, 2).map(([initials, name]) => (
<AvatarRoot key={initials} role="img" aria-label={name}>
<AvatarFallback>{initials}</AvatarFallback>
</AvatarRoot>
))}
</AvatarGroup>
</div>
)
}
Props
Keyboard and accessibility
- Give the group an
aria-labeloraria-labelledbythat says who the faces are; it rendersrole="group". - Name each avatar, by the
altof its picture or byaria-labelonAvatarRoot: initials alone are silent. - The counter is read as
labels.avatar.more("1 more"); for Arabic passlabelsandlocalefromgetLabels('ar').
Right to left
The row starts at the inline start and each face overlaps the one before it, so in Arabic it starts on the right; the +N keeps its plus before the digits.
Server and client
Renders on the server and ships no JavaScript.
Files: index.tsx.
On the chip: server - 0 KB.