Zum Inhalt springen
UI Kit
Accent

Avatar

Menschen als Bild oder Initialen, mit Status, im Stapel und mit Namen.

Eine von 5 Components in Data Display

4 Variants

Variants

Größen und Arten

Sechs Größen für Bild, Initialen, eckig und Status.

Gruppe

Überlappende Stapel mit Zähler, in einer Projektkarte und drei Größen.

Anwesenheit

Avatar mit Name, Rolle und Status in einer Teamliste.

Profilkarte

Ein großer Avatar über einem Titelbild, mit Aktionen und drei Zahlen.

Details

Was Avatar mitbringt, aus Registry und Quellcode gelesen.

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

Props

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

  • namePflicht
    string

    Full name, used for initials, the alt text and the tone

  • srcoptional
    string

    Image URL. Falls back to initials when it fails to load.

  • altoptional
    string

    Alt text, defaults to the name. Pass "" when the name is written next to the avatar.

  • imageoptional
    React.ReactNode

    Any drawn or custom image, shown instead of src

  • sizeoptional
    AvatarSize

    xssmmdlgxl2xl

  • shapeoptional
    'circle' | 'square'
  • statusoptional
    AvatarStatus

    onlineawaybusyoffline

  • toneoptional
    AvatarTone

    autoaccentneutralsolid

    Color of the initials. auto picks a calm tone from the name.

  • ringoptional
    boolean

    Ring in the page background, for avatars on images or in stacks

Eingebaute Texte

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

Alle 6 Texte anzeigen
onlineEnglisch onlineDeutsch online
awayEnglisch awayDeutsch abwesend
busyEnglisch busyDeutsch beschäftigt
offlineEnglisch offlineDeutsch offline
moreEnglisch moreDeutsch weitere
peopleEnglisch peopleDeutsch Personen

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.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

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, 248 Zeilen
components/ui/avatar.tsx
'use client';

import { useRef, useState } from 'react';
import { cn, useInView, useTexts } from '@/lib/kit';

export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
export type AvatarStatus = 'online' | 'away' | 'busy' | 'offline';
export type AvatarTone = 'auto' | 'accent' | 'neutral' | 'solid';

const SIZE: Record<AvatarSize, { box: string; text: string; dot: string; px: number }> = {
  xs: { box: 'size-6', text: 'text-[9px]', dot: 'size-2', px: 24 },
  sm: { box: 'size-8', text: 'text-[11px]', dot: 'size-2.5', px: 32 },
  md: { box: 'size-10', text: 'text-[13px]', dot: 'size-3', px: 40 },
  lg: { box: 'size-12', text: 'text-[15px]', dot: 'size-3', px: 48 },
  xl: { box: 'size-16', text: 'text-[19px]', dot: 'size-3.5', px: 64 },
  '2xl': { box: 'size-20', text: 'text-[23px]', dot: 'size-4', px: 80 },
};

const TONES = [
  'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]',
  'bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] text-foreground',
  'bg-[color-mix(in_oklab,var(--primary)_26%,var(--card))] text-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]',
  'bg-[color-mix(in_oklab,var(--foreground)_16%,var(--card))] text-foreground',
];
const TONE: Record<Exclude<AvatarTone, 'auto'>, string> = {
  accent: TONES[0],
  neutral: TONES[1],
  solid: 'bg-primary text-primary-foreground',
};

/* Online breathes a quiet ring, a stack pops in from the start when it scrolls into view */
const STYLES =
  '.kitfx-av-live{animation:kitfx-av-live 2.8s var(--ease-kit) infinite}@keyframes kitfx-av-live{0%{opacity:.6;transform:scale(1)}70%,100%{opacity:0;transform:scale(2.2)}}' +
  '.kitfx-av-in>.kitfx-av-item{animation:kitfx-av-in .55s cubic-bezier(.34,1.45,.64,1) both;animation-delay:calc(var(--i,0) * 60ms)}' +
  '@keyframes kitfx-av-in{from{opacity:0;transform:translateX(-10px) scale(.7)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-av-live{animation:none;opacity:0}.kitfx-av-in>.kitfx-av-item{animation:none}}';

/** How far each avatar moves apart when the stack fans out, per size */
const FAN: Record<AvatarSize, number> = { xs: 3, sm: 4, md: 5, lg: 6, xl: 8, '2xl': 9 };

const STATUS: Record<AvatarStatus, string> = {
  online: 'bg-success',
  away: 'bg-warning',
  busy: 'bg-destructive',
  offline: 'bg-background shadow-[inset_0_0_0_1.5px_var(--subtle-foreground)]',
};

const TEXTS = {
  en: { online: 'online', away: 'away', busy: 'busy', offline: 'offline', more: 'more', people: 'people' },
  de: { online: 'online', away: 'abwesend', busy: 'beschäftigt', offline: 'offline', more: 'weitere', people: 'Personen' },
};
type AvatarTexts = (typeof TEXTS)['en'];

/** Up to two initials, from the first and the last word */
export function initials(name: string) {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (!parts.length) return '?';
  const first = parts[0][0] ?? '';
  const last = parts.length > 1 ? parts[parts.length - 1][0] ?? '' : '';
  return (first + last).toUpperCase();
}

const hash = (s: string) => [...s].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);

export interface AvatarProps {
  /** Full name, used for initials, the alt text and the tone */
  name: string;
  /** Image URL. Falls back to initials when it fails to load. */
  src?: string;
  /** Alt text, defaults to the name. Pass "" when the name is written next to the avatar. */
  alt?: string;
  /** Any drawn or custom image, shown instead of src */
  image?: React.ReactNode;
  size?: AvatarSize;
  shape?: 'circle' | 'square';
  status?: AvatarStatus;
  /** Color of the initials. auto picks a calm tone from the name. */
  tone?: AvatarTone;
  /** Ring in the page background, for avatars on images or in stacks */
  ring?: boolean;
  labels?: Partial<AvatarTexts>;
  className?: string;
}

/** A person as image or initials, with an optional presence dot. */
export function Avatar({ name, src, alt, image, size = 'md', shape = 'circle', status, tone = 'auto', ring, labels, className }: AvatarProps) {
  const t = useTexts(TEXTS, labels);
  const [failed, setFailed] = useState(false);
  const s = SIZE[size];
  const round = shape === 'circle' ? 'rounded-full' : size === 'xs' || size === 'sm' ? 'rounded-md' : 'rounded-lg';
  const toneClass = tone === 'auto' ? TONES[hash(name) % TONES.length] : TONE[tone];
  const showImage = image || (src && !failed);
  return (
    <span className={cn('relative inline-flex shrink-0 align-middle', s.box, className)}>
      {/* The picture is cropped to the shape on purpose */}
      <span
        data-clip-ok
        className={cn(
          'relative flex size-full select-none items-center justify-center overflow-hidden font-semibold tracking-[-0.02em]',
          round,
          s.text,
          !showImage && toneClass,
          'shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_8%,transparent)]',
          ring && 'ring-2 ring-background',
        )}
      >
        {image ? (
          <span className="absolute inset-0 [&>*]:size-full" {...(alt === '' ? { 'aria-hidden': true } : { role: 'img', 'aria-label': alt ?? name })}>
            {image}
          </span>
        ) : src && !failed ? (
          <img src={src} alt={alt ?? name} width={s.px} height={s.px} loading="lazy" decoding="async" onError={() => setFailed(true)} className="size-full object-cover" />
        ) : (
          <>
            <span aria-hidden>{initials(name)}</span>
            {alt !== '' && <span className="sr-only">{name}</span>}
          </>
        )}
        {showImage && <span aria-hidden className={cn('pointer-events-none absolute inset-0 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_10%,transparent)]', round)} />}
      </span>
      {status && (
        <span
          className={cn(
            'absolute block rounded-full ring-2 ring-background',
            s.dot,
            shape === 'circle' ? 'bottom-[4%] right-[4%] translate-x-[15%] translate-y-[15%]' : '-bottom-0.5 -right-0.5',
            STATUS[status],
          )}
        >
          {status === 'online' && (
            <>
              <style href="kitfx-av" precedence="default">
                {STYLES}
              </style>
              <span aria-hidden className="kitfx-av-live pointer-events-none absolute inset-0 rounded-full bg-success" />
            </>
          )}
          <span className="sr-only">{t[status]}</span>
        </span>
      )}
    </span>
  );
}

export interface AvatarGroupPerson {
  name: string;
  src?: string;
  image?: React.ReactNode;
}

export interface AvatarGroupProps {
  people: AvatarGroupPerson[];
  /** How many avatars to show before the +N chip */
  max?: number;
  size?: AvatarSize;
  shape?: 'circle' | 'square';
  /** Accessible name of the group, e.g. "Project team" */
  label?: string;
  labels?: Partial<AvatarTexts>;
  className?: string;
}

const OVERLAP: Record<AvatarSize, string> = {
  xs: '-ml-1.5',
  sm: '-ml-2',
  md: '-ml-2.5',
  lg: '-ml-3',
  xl: '-ml-4',
  '2xl': '-ml-5',
};

/**
 * Overlapping avatars with a +N chip. They pop in one after another when the stack scrolls into view,
 * fan out on hover and keyboard focus, and the one under the pointer lifts.
 */
export function AvatarGroup({ people, max = 4, size = 'md', shape = 'circle', label, labels, className }: AvatarGroupProps) {
  const t = useTexts(TEXTS, labels);
  const shown = people.slice(0, max);
  const rest = people.length - shown.length;
  const s = SIZE[size];
  const [ref, inView] = useInView<HTMLDivElement>();
  const waited = useRef(false);
  if (!inView) waited.current = true;
  const entering = inView && waited.current;
  const fan = (i: number) => ({ '--i': i, '--fan': `${i * FAN[size]}px` }) as React.CSSProperties;
  return (
    <div
      ref={ref}
      role="group"
      aria-label={label ?? `${people.length} ${t.people}`}
      className={cn('group/avatars flex items-center', !inView && '[&>.kitfx-av-item]:opacity-0', entering && 'kitfx-av-in', className)}
    >
      <style href="kitfx-av" precedence="default">
        {STYLES}
      </style>
      {shown.map((p, i) => (
        <span
          key={p.name + i}
          title={p.name}
          className={cn(
            'kitfx-av-item relative transition-[translate] duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-hover/avatars:translate-x-[var(--fan)] group-focus-within/avatars:translate-x-[var(--fan)] motion-reduce:transition-none',
            i > 0 && OVERLAP[size],
          )}
          style={{ ...fan(i), zIndex: shown.length - i }}
        >
          <span className="block transition-transform duration-200 ease-[var(--ease-kit)] hover:-translate-y-1 motion-reduce:transition-none">
            <Avatar name={p.name} src={p.src} image={p.image} size={size} shape={shape} ring labels={labels} />
          </span>
        </span>
      ))}
      {rest > 0 && (
        <span
          style={fan(shown.length)}
          className={cn(
            'kitfx-av-item relative inline-flex shrink-0 items-center justify-center bg-muted font-medium tabular-nums text-muted-foreground ring-2 ring-background transition-[translate] duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] group-hover/avatars:translate-x-[var(--fan)] group-focus-within/avatars:translate-x-[var(--fan)] motion-reduce:transition-none',
            OVERLAP[size],
            s.box,
            s.text,
            shape === 'circle' ? 'rounded-full' : 'rounded-lg',
          )}
        >
          <span aria-hidden>+{rest}</span>
          <span className="sr-only">
            {rest} {t.more}
          </span>
        </span>
      )}
    </div>
  );
}

export interface AvatarLabelProps extends AvatarProps {
  /** Second line, e.g. role or email */
  description?: React.ReactNode;
}

/** Avatar with name and a second line, for lists, comments and authors. */
export function AvatarLabel({ description, className, ...avatar }: AvatarLabelProps) {
  return (
    <span className={cn('inline-flex min-w-0 items-center gap-3', className)}>
      <Avatar alt="" {...avatar} />
      <span className="min-w-0 leading-tight">
        <span className="block truncate text-sm font-medium text-foreground">{avatar.name}</span>
        {description && <span className="mt-0.5 block truncate text-[13px] text-muted-foreground">{description}</span>}
      </span>
    </span>
  );
}

Häufige Fragen

Wie installiere ich Avatar?

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

Welche Varianten hat Avatar?

4 Varianten, Größen und Arten, Gruppe, Anwesenheit und Profilkarte.

Kann ich die Texte von Avatar übersetzen?

Ja. Avatar bringt 6 eigene Texte auf Deutsch und Englisch mit, etwa „online“, „abwesend“ und „beschäftigt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Avatar einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Avatar mit aiw_components und installieren sie mit aiw_add @ai-website/avatar.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert