Zum Inhalt springen
UI Kit
Accent

Avatar-Gruppe

Menschen, die zusammengehören: ein Stapel, dessen +N die ganze Liste öffnet, eine Reihe mit Anwesenheit und wer gerade bearbeitet, ein Satz, der die ersten nennt und den Rest zählt, in fünf Größen, in Karten und Zeilen.

5 VariantsEine von 9 Components in Data Display

Menschen, die zusammengehören

Eine Avatar-Gruppe zeigt, wer an einem Projekt, in einem Dokument oder bei einem Event dabei ist, mehr als eine Reihe überlappender Gesichter. Der Rest hinter dem Zähler bleibt erreichbar, Anwesenheit ist sichtbar, und der Ring nimmt die Farbe der Fläche darunter an, Stapel wirken also auf jedem Grund sauber.

„Stapel mit Liste“ zeigt vier Gesichter und einen Zähler auf jeder Projektkarte, der Zähler ist ein Knopf, der alle mit Rolle in einem Popover öffnet, Escape schließt und der Fokus kehrt zurück. „Wer gerade da ist“ ist eine Gesichterreihe in der Leiste eines geteilten Dokuments mit Statuspunkten, einem atmenden Akzentring für alle, die gerade bearbeiten, Namen beim Zeigen und einem Einladeknopf. „Namen und Anzahl“ schreibt einen Satz wie „Lena, Jonas und 12 weitere haben zugesagt“, der Rest ist ein Link zur ganzen Liste. „Größen und Formen“ deckt fünf Größen von 24 bis 64 px ab, rund oder eckig. „In Tabellenzeilen“ setzt kleine Stapel in eine Projekttabelle, am Telefon als zweizeilige Karten.

Zeig drei bis fünf Gesichter und einen Zähler für den Rest, mehr Gesichter sind nicht mehr lesbar. Der Satz mit Namen wirkt am besten auf Event- und Community-Seiten, wo Namen zählen. Mach den Zähler zu einem echten Knopf, Menschen wollen sehen, wer dahinter steht.

Variants

Stapel mit ListeNeu in 1.2

Vier Gesichter und ein Zähler auf jeder Projektkarte. Der Zähler ist ein Knopf, er öffnet alle mit Rolle in einem Popover, Escape schließt es und der Fokus kehrt zurück.

Wer gerade da istNeu in 1.2

Eine Gesichterreihe in der Leiste eines geteilten Dokuments. Statuspunkte, ein atmender Akzentring für alle, die gerade bearbeiten, Namen beim Zeigen und Fokussieren, ein Druck springt zur Person und der letzte Knopf lädt ein.

Namen und AnzahlNeu in 1.2

Wenige Gesichter und ein Satz wie „Lena, Jonas und 12 weitere haben zugesagt“. Der Rest ist ein Link zur ganzen Liste, kurze Listen werden so verbunden, wie die Sprache es tut.

Größen und FormenNeu in 1.2

Fünf Größen von 24 bis 64 px, rund oder eckig. Überlappung und Ring wachsen mit der Größe, der Ring nimmt die Farbe der Fläche darunter an.

In TabellenzeilenNeu in 1.2

Kleine Stapel in einer Projekttabelle, einer je Zeile, alle auf derselben Linie. Am Telefon wird jede Zeile eine zweizeilige Karte mit dem Stapel unter dem Namen.

Details

Was Avatar-Gruppe mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Data Display
Bereich
Elements
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/avatar-group.tsx
Bringt mit
utilskiticonavatarpopovertooltip
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 8 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle AvatarStackProps aus components/ui/avatar-group.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • peoplePflicht
    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

Eingebaute Texte

Diese Texte schreibt Avatar-Gruppe selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 8 Texte anzeigen
peopleEnglisch peopleDeutsch Personen
showAllEnglisch Show all {n} peopleDeutsch Alle {n} Personen zeigen
othersEnglisch {n} othersDeutsch {n} weitere
otherEnglisch 1 otherDeutsch 1 weitere Person
andEnglisch andDeutsch und
hereEnglisch {n} here nowDeutsch {n} gerade da
activeEnglisch editingDeutsch bearbeitet
addEnglisch Add personDeutsch Person hinzufügen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle group.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 356 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Avatar-Gruppe?

Mit aiw add @ai-website/avatar-group. Der Befehl legt die Datei components/ui/avatar-group.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, avatar, popover und tooltip. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Avatar-Gruppe?

5 Varianten, Stapel mit Liste, Wer gerade da ist, Namen und Anzahl, Größen und Formen und In Tabellenzeilen. Du wählst sie mit der Prop variant, zum Beispiel variant="summary".

Kann ich die Texte von Avatar-Gruppe übersetzen?

Ja. Avatar-Gruppe bringt 8 eigene Texte auf Deutsch und Englisch mit, etwa „Personen“, „1 weitere Person“ und „und“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Avatar-Gruppe einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Avatar-Gruppe mit aiw_components und installieren sie mit aiw_add @ai-website/avatar-group. Nenn im Prompt die Variante, etwa variant="facepile", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert