Skip to content
UI Kit
Accent color

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.

5 variantsOne of 9 components in Data display

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

Stack with listNew in 1.2

Four faces and a counter on each project card. The counter is a button, it opens everyone with role in a popover, Escape closes it and the focus returns.

Who is hereNew in 1.2

A facepile in the bar of a shared document. Presence dots, an accent ring that breathes for whoever is editing, a name tip on hover and focus, a press jumps to that person and the last button invites.

Names and a countNew in 1.2

A few faces and a sentence like "Lena, Jonas and 12 others are going". The rest is a link to the full list, short lists are joined the way the language does it.

Sizes and shapesNew in 1.2

Five sizes from 24 to 64 px, round or square. The overlap and the ring grow with the size, the ring takes the color of the surface below.

In table rowsNew in 1.2

Small stacks in a project table, one per row, all on the same line. On phones each row becomes a two line card with the stack under the name.

Details

What Avatar group brings along, read from the registry and the source.

Category
Data display
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/avatar-group.tsx
Brings along
utilskiticonavatarpopovertooltip
npm packages
None
Languages
English and German, 8 texts of its own
Studio
Adjust in code

Props

The AvatarStackProps interface from components/ui/avatar-group.tsx, with the comments from the code.

  • peoplerequired
    StackPerson[]
  • variantoptional
    AvatarStackVariant

    stackfacepilesummary

  • maxoptional
    number

    Avatars shown before the counter

  • sizeoptional
    AvatarSize
  • shapeoptional
    'circle' | 'square'
  • ringoptional
    'background' | 'card' | 'surface' | 'muted'

    Color of the ring between overlapping avatars: the surface the stack sits on

  • labeloptional
    string

    Accessible name and list title, e.g. "Project team"

  • listoptional
    boolean

    stack and summary: the counter opens a list of everyone. Off: a plain counter.

  • namesoptional
    number

    summary: how many names are written out

  • suffixoptional
    string

    summary: the words after the names, e.g. "are going"

  • onAddoptional
    () => void

    facepile: a round button at the end, e.g. to invite

  • onPersonClickoptional
    (person: StackPerson) => void

    facepile: 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
peopleEnglish peopleGerman Personen
showAllEnglish Show all {n} peopleGerman Alle {n} Personen zeigen
othersEnglish {n} othersGerman {n} weitere
otherEnglish 1 otherGerman 1 weitere Person
andEnglish andGerman und
hereEnglish {n} here nowGerman {n} gerade da
activeEnglish editingGerman bearbeitet
addEnglish Add personGerman 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
components/ui/avatar-group.tsx
'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.

Often searched

Jump to

Copied to the clipboard