Avatar group
People who belong together: a stack whose +N opens the full list, a facepile with presence and who is editing, a sentence that names the first and counts the rest, in five sizes, in cards and rows.
People who belong together
An avatar group shows who is on a project, in a document or at an event, more than a row of overlapping faces. The rest behind the counter stays reachable, presence is visible and the ring takes the color of the surface below, so stacks look clean on any background.
Stack with list shows four faces and a counter on each project card, the counter is a button that opens everyone with role in a popover, Escape closes it and the focus returns. Who is here is a facepile in the bar of a shared document with presence dots, a breathing accent ring for whoever is editing, name tips and an invite button. Names and a count writes a sentence like Lena, Jonas and 12 others are going, with the rest as a link to the full list. Sizes and shapes covers five sizes from 24 to 64 px, round or square. In table rows puts small stacks in a project table, two line cards on phones.
Show three to five faces and a count for the rest, more faces stop being readable. The summary sentence works best on event and community pages, where names matter. Make the counter a real button, people want to see who is behind it.
Variants
Details
What Avatar group brings along, read from the registry and the source.
- Category
- Data display
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/avatar-group.tsx
- npm packages
- None
- Languages
- English and German, 8 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AvatarStackProps interface from components/ui/avatar-group.tsx, with the comments from the code.
peoplerequiredStackPerson[]variantoptionalAvatarStackVariantstackfacepilesummary
maxoptionalnumberAvatars shown before the counter
sizeoptionalAvatarSizeshapeoptional'circle' | 'square'ringoptional'background' | 'card' | 'surface' | 'muted'Color of the ring between overlapping avatars: the surface the stack sits on
labeloptionalstringAccessible name and list title, e.g. "Project team"
listoptionalbooleanstack and summary: the counter opens a list of everyone. Off: a plain counter.
namesoptionalnumbersummary: how many names are written out
suffixoptionalstringsummary: the words after the names, e.g. "are going"
onAddoptional() => voidfacepile: a round button at the end, e.g. to invite
onPersonClickoptional(person: StackPerson) => voidfacepile: a press on a person, e.g. jump to their cursor
Built-in texts
Avatar group writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 8 texts
| Key | English | German |
|---|---|---|
| people | English people | German Personen |
| showAll | English Show all {n} people | German Alle {n} Personen zeigen |
| others | English {n} others | German {n} weitere |
| other | English 1 other | German 1 weitere Person |
| and | English and | German und |
| here | English {n} here now | German {n} gerade da |
| active | English editing | German bearbeitet |
| add | English Add person | German Person hinzufügen |
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role group.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 356 lines
'use client';
import { Fragment, useRef } from 'react';
import { cn, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Avatar, type AvatarSize, type AvatarStatus } from '@/components/ui/avatar';
import { Popover } from '@/components/ui/popover';
import { Tooltip } from '@/components/ui/tooltip';
/*
* Avatar groups for people who belong together: a stack whose +N opens the
* full list, a facepile that shows who is here right now (presence and who
* is editing), and a summary line that names the first people and counts the
* rest ("Anna, Ben and 12 others"). The plain overlapping avatars of the
* avatar element stay the quiet base, this one adds the list, the presence and
* the sentence.
*/
export type AvatarStackVariant = 'stack' | 'facepile' | 'summary';
export interface StackPerson {
name: string;
/** Drawn or real picture, initials without it */
image?: React.ReactNode;
status?: AvatarStatus;
/** Second line in the list, e.g. role */
role?: string;
/** Working right now (editing, speaking): an accent ring that breathes */
active?: boolean;
}
const TEXTS = {
en: {
people: 'people',
showAll: 'Show all {n} people',
others: '{n} others',
other: '1 other',
and: 'and',
here: '{n} here now',
active: 'editing',
add: 'Add person',
},
de: {
people: 'Personen',
showAll: 'Alle {n} Personen zeigen',
others: '{n} weitere',
other: '1 weitere Person',
and: 'und',
here: '{n} gerade da',
active: 'bearbeitet',
add: 'Person hinzufügen',
},
};
type StackTexts = (typeof TEXTS)['en'];
const OVERLAP: Record<AvatarSize, string> = { xs: '-ms-1.5', sm: '-ms-2', md: '-ms-2.5', lg: '-ms-3', xl: '-ms-4', '2xl': '-ms-5' };
const GAP: Record<AvatarSize, string> = { xs: 'gap-1', sm: 'gap-1.5', md: 'gap-2', lg: 'gap-2.5', xl: 'gap-3', '2xl': 'gap-3' };
const BOX: Record<AvatarSize, string> = { xs: 'size-6 text-[9px]', sm: 'size-8 text-[11px]', md: 'size-10 text-[12px]', lg: 'size-12 text-[14px]', xl: 'size-16 text-[17px]', '2xl': 'size-20 text-[20px]' };
const STYLES =
'.kitfx-avg-in>.kitfx-avg-item{animation:kitfx-avg-in .5s cubic-bezier(.34,1.45,.64,1) both;animation-delay:calc(var(--i,0) * 55ms)}' +
'@keyframes kitfx-avg-in{from{opacity:0;transform:translateX(-8px) scale(.72)}}' +
'.kitfx-avg-live{animation:kitfx-avg-live 2.4s ease-in-out infinite}@keyframes kitfx-avg-live{50%{box-shadow:0 0 0 2px var(--avg-ring),0 0 0 5px color-mix(in oklab,var(--primary) 26%,transparent)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-avg-in>.kitfx-avg-item{animation:none}.kitfx-avg-live{animation:none}}';
const fill = (s: string, n: number) => s.replace('{n}', String(n));
export interface AvatarStackProps {
people: StackPerson[];
variant?: AvatarStackVariant;
/** Avatars shown before the counter */
max?: number;
size?: AvatarSize;
shape?: 'circle' | 'square';
/** Color of the ring between overlapping avatars: the surface the stack sits on */
ring?: 'background' | 'card' | 'surface' | 'muted';
/** Accessible name and list title, e.g. "Project team" */
label?: string;
/** stack and summary: the counter opens a list of everyone. Off: a plain counter. */
list?: boolean;
/** summary: how many names are written out */
names?: number;
/** summary: the words after the names, e.g. "are going" */
suffix?: string;
/** facepile: a round button at the end, e.g. to invite */
onAdd?: () => void;
/** facepile: a press on a person, e.g. jump to their cursor */
onPersonClick?: (person: StackPerson) => void;
labels?: Partial<StackTexts>;
className?: string;
}
const RING = { background: 'var(--background)', card: 'var(--card)', surface: 'var(--surface)', muted: 'var(--muted)' };
/** The people list inside the popover */
function PeopleList({ people, title, size }: { people: StackPerson[]; title: string; size?: AvatarSize }) {
const t = useTexts(TEXTS);
return (
<div className="-m-1">
<p className="px-1 pb-2 text-[12px] font-medium text-subtle-foreground">
{title} · {people.length}
</p>
<ul className="kit-scrollbar relative -mx-1 max-h-72 overflow-y-auto">
{people.map((p, i) => (
<li key={p.name + i} className="flex items-center gap-3 rounded-lg px-2 py-1.5">
<Avatar name={p.name} image={p.image} status={p.status} size={size === 'xs' ? 'sm' : 'sm'} alt="" />
<span className="min-w-0 flex-1 leading-tight">
<span className="block truncate text-[13px] font-medium text-foreground">{p.name}</span>
{(p.role || p.active) && (
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{p.role}
{p.role && p.active ? ' · ' : ''}
{p.active && <span className="text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{t.active}</span>}
</span>
)}
</span>
</li>
))}
</ul>
</div>
);
}
/**
* People who belong together, as an overlapping stack with a +N list, a facepile with presence,
* or a sentence that names the first ones and counts the rest.
*/
export function AvatarStack({
people,
variant = 'stack',
max = 4,
size = 'md',
shape = 'circle',
ring = 'background',
label,
list = true,
names = 2,
suffix,
onAdd,
onPersonClick,
labels,
className,
}: AvatarStackProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const [ref, inView] = useInView<HTMLDivElement>();
const waited = useRef(false);
if (!inView) waited.current = true;
const entering = inView && waited.current;
const title = label ?? `${people.length} ${t.people}`;
const shownCount = variant === 'summary' ? Math.min(max, 3) : max;
const shown = people.slice(0, shownCount);
const rest = Math.max(0, people.length - shown.length);
const radius = shape === 'circle' ? 'rounded-full' : size === 'xs' || size === 'sm' ? 'rounded-md' : 'rounded-lg';
const ringStyle = { '--avg-ring': RING[ring] } as React.CSSProperties;
const ringShadow = 'shadow-[0_0_0_2px_var(--avg-ring)]';
const counterClass = cn(
'relative inline-flex shrink-0 items-center justify-center bg-muted font-semibold tabular-nums text-muted-foreground',
ringShadow,
BOX[size],
radius,
);
const counter =
rest > 0 &&
(list ? (
<Popover
label={title}
align="start"
className="w-72"
trigger={
<button
type="button"
aria-label={fill(t.showAll, people.length)}
className={cn(
counterClass,
'cursor-pointer outline-none transition-[background-color,color,scale] duration-200 ease-[var(--ease-kit)] after:absolute after:-inset-1 after:content-[""] hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring active:scale-95 aria-expanded:bg-accent aria-expanded:text-foreground motion-reduce:active:scale-100',
(size === 'xs' || size === 'sm') && 'after:-inset-2.5',
)}
>
+{rest}
</button>
}
>
<PeopleList people={people} title={title} size={size} />
</Popover>
) : (
<span className={counterClass}>
<span aria-hidden>+{rest}</span>
<span className="sr-only">{fill(t.others, rest)}</span>
</span>
));
/* ---------- summary: avatars and a sentence ---------- */
if (variant === 'summary') {
const written = people.slice(0, Math.max(1, names));
const more = people.length - written.length;
const lf = new Intl.ListFormat(locale === 'de' ? 'de' : 'en', { type: 'conjunction' });
const nameNodes = written.map((p, i) => (
<strong key={p.name + i} className="font-semibold text-foreground">
{p.name.split(' ')[0]}
</strong>
));
const moreText = more === 1 ? t.other : fill(t.others, more);
const moreNode =
more > 0 && list ? (
<Popover
label={title}
align="start"
className="w-72"
trigger={
<button
type="button"
className="relative cursor-pointer rounded-sm font-semibold text-foreground underline decoration-[color-mix(in_oklab,var(--foreground)_30%,transparent)] underline-offset-[3px] outline-none transition-[text-decoration-color] after:absolute after:-inset-x-1 after:-inset-y-3 after:content-[''] hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring"
>
{moreText}
</button>
}
>
<PeopleList people={people} title={title} />
</Popover>
) : more > 0 ? (
<strong className="font-semibold text-foreground">{moreText}</strong>
) : null;
// Names joined in the way of the language, the last part with "and"
const parts: React.ReactNode[] = [];
if (more > 0) {
nameNodes.forEach((n, i) => {
if (i) parts.push(', ');
parts.push(n);
});
parts.push(` ${t.and} `, moreNode);
} else {
const pattern = lf.formatToParts(written.map((_, i) => `\u0000${i}`));
pattern.forEach((p) => parts.push(p.type === 'element' ? nameNodes[Number(p.value.slice(1))] : p.value));
}
return (
<div ref={ref} style={ringStyle} className={cn('flex min-w-0 items-center gap-3', className)}>
<style href="kitfx-avg" precedence="default">
{STYLES}
</style>
<span aria-hidden className={cn('flex shrink-0 items-center', !inView && '[&>.kitfx-avg-item]:opacity-0', entering && 'kitfx-avg-in')}>
{shown.map((p, i) => (
<span key={p.name + i} className={cn('kitfx-avg-item relative flex', radius, ringShadow, i > 0 && OVERLAP[size])} style={{ '--i': i, zIndex: shown.length - i } as React.CSSProperties}>
<Avatar name={p.name} image={p.image} size={size} shape={shape} alt="" />
</span>
))}
</span>
<p className={cn('min-w-0 text-pretty leading-snug text-muted-foreground', size === 'xs' || size === 'sm' ? 'text-[13px]' : 'text-sm')}>
{parts.map((part, i) => (
<Fragment key={i}>{part}</Fragment>
))}
{suffix ? ` ${suffix}` : ''}
</p>
</div>
);
}
/* ---------- facepile: presence, who is working ---------- */
if (variant === 'facepile') {
const here = people.filter((p) => p.status === 'online' || p.active).length;
return (
<div
ref={ref}
role="group"
aria-label={`${title}, ${fill(t.here, here)}`}
style={ringStyle}
className={cn('flex items-center', GAP[size], !inView && '[&>.kitfx-avg-item]:opacity-0', entering && 'kitfx-avg-in', className)}
>
<style href="kitfx-avg" precedence="default">
{STYLES}
</style>
{shown.map((p, i) => {
const desc = [p.role, p.active ? t.active : undefined].filter(Boolean).join(' · ');
return (
<span key={p.name + i} className="kitfx-avg-item flex" style={{ '--i': i } as React.CSSProperties}>
<Tooltip content={desc ? `${p.name} · ${desc}` : p.name} delay={200}>
<button
type="button"
aria-label={desc ? `${p.name}, ${desc}` : p.name}
onClick={() => onPersonClick?.(p)}
className={cn(
'relative flex cursor-pointer outline-none transition-[translate,opacity] duration-200 ease-[var(--ease-kit)] after:absolute after:-inset-1 after:content-[""] hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-ring motion-reduce:hover:translate-y-0',
radius,
(size === 'xs' || size === 'sm') && 'after:-inset-2',
p.status === 'offline' && !p.active && 'opacity-55 hover:opacity-100',
)}
>
<span
className={cn('flex', radius, p.active ? 'kitfx-avg-live shadow-[0_0_0_2px_var(--avg-ring),0_0_0_4px_var(--primary)]' : '')}
>
<Avatar name={p.name} image={p.image} size={size} shape={shape} status={p.status} alt="" />
</span>
</button>
</Tooltip>
</span>
);
})}
{rest > 0 && <span className="kitfx-avg-item flex" style={{ '--i': shown.length } as React.CSSProperties}>{counter}</span>}
{onAdd && (
<span className="kitfx-avg-item flex" style={{ '--i': shown.length + 1 } as React.CSSProperties}>
<Tooltip content={t.add} delay={200}>
<button
type="button"
aria-label={t.add}
onClick={onAdd}
className={cn(
'relative grid shrink-0 cursor-pointer place-items-center border border-dashed border-border-strong text-muted-foreground outline-none transition-[background-color,color,border-color] duration-200 after:absolute after:-inset-1 after:content-[""] hover:border-solid hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring',
BOX[size],
radius,
(size === 'xs' || size === 'sm') && 'after:-inset-2',
)}
>
<Icon name="plus" size={size === 'xs' ? 12 : size === 'sm' ? 14 : 16} />
</button>
</Tooltip>
</span>
)}
</div>
);
}
/* ---------- stack: overlap and a +N list ---------- */
return (
<div
ref={ref}
role="group"
aria-label={title}
style={ringStyle}
className={cn('group/avg flex items-center', !inView && '[&>.kitfx-avg-item]:opacity-0', entering && 'kitfx-avg-in', className)}
>
<style href="kitfx-avg" precedence="default">
{STYLES}
</style>
{shown.map((p, i) => (
<span
key={p.name + i}
title={p.name}
className={cn('kitfx-avg-item relative', i > 0 && OVERLAP[size])}
style={{ '--i': i, zIndex: shown.length - i + 1 } as React.CSSProperties}
>
{/* The ring travels with the avatar when it lifts */}
<span className={cn('flex transition-transform duration-200 ease-[var(--ease-kit)] hover:-translate-y-1 motion-reduce:transition-none', radius, ringShadow)}>
<Avatar name={p.name} image={p.image} size={size} shape={shape} />
</span>
</span>
))}
{rest > 0 && (
<span className={cn('kitfx-avg-item relative z-0 flex', OVERLAP[size])} style={{ '--i': shown.length } as React.CSSProperties}>
{counter}
</span>
)}
</div>
);
}Frequent questions
How do I install Avatar group?
With aiw add @ai-website/avatar-group. The command puts components/ui/avatar-group.tsx into your project, the code is yours from then on. It brings along icon, avatar, popover and tooltip. It needs no further npm packages.
Which variants does Avatar group have?
5 variants, Stack with list, Who is here, Names and a count, Sizes and shapes and In table rows. You pick one with the variant prop, for example variant="summary".
Can I translate the texts of Avatar group?
Yes. Avatar group carries 8 texts of its own in English and German, such as “people”, “1 other” and “and”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Avatar group?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Avatar group with aiw_components and install it with aiw_add @ai-website/avatar-group. Name the variant in your prompt, for example variant="facepile", and the assistant writes your content straight into the props.