Skip to content
UI Kit
Accent color

Avatar

People as image or initials, with presence, in stacks and with a name.

One of 5 components in Data display

4 variants

Variants

Sizes and kinds

Six sizes for image, initials, square and status.

Group

Overlapping stacks with a counter, in a project card and three sizes.

Presence list

Avatar with name, role and status in a team list.

Profile card

A large avatar over a cover image, with actions and three numbers.

Details

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

Category
Data display
Variants
4
Version
1.0.0 · since 1.0.0
File
components/ui/avatar.tsx
Brings along
utilskit
npm packages
None
Languages
English and German, 6 texts of its own
Studio
Adjust in code

Props

The AvatarProps interface from components/ui/avatar.tsx, with the comments from the code.

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

Built-in texts

Avatar writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 6 texts
onlineEnglish onlineGerman online
awayEnglish awayGerman abwesend
busyEnglish busyGerman beschäftigt
offlineEnglish offlineGerman offline
moreEnglish moreGerman weitere
peopleEnglish peopleGerman Personen

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.
  • The server renders the starting state in full, JavaScript only adds the interaction.

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, 248 lines
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>
  );
}

Frequent questions

How do I install Avatar?

With aiw add @ai-website/avatar. The command puts components/ui/avatar.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Avatar have?

4 variants, Sizes and kinds, Group, Presence list and Profile card.

Can I translate the texts of Avatar?

Yes. Avatar carries 6 texts of its own in English and German, such as “online”, “away” and “busy”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Avatar?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Avatar with aiw_components and install it with aiw_add @ai-website/avatar.

Often searched

Jump to

Copied to the clipboard