Avatar
People as image or initials, with presence, in stacks and with a name.
One of 5 components in Data display
Variants
Details
What Avatar brings along, read from the registry and the source.
- Category
- Data display
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AvatarProps interface from components/ui/avatar.tsx, with the comments from the code.
namerequiredstringFull name, used for initials, the alt text and the tone
srcoptionalstringImage URL. Falls back to initials when it fails to load.
altoptionalstringAlt text, defaults to the name. Pass "" when the name is written next to the avatar.
imageoptionalReact.ReactNodeAny drawn or custom image, shown instead of src
sizeoptionalAvatarSizexssmmdlgxl2xl
shapeoptional'circle' | 'square'statusoptionalAvatarStatusonlineawaybusyoffline
toneoptionalAvatarToneautoaccentneutralsolid
Color of the initials. auto picks a calm tone from the name.
ringoptionalbooleanRing 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
| Key | English | German |
|---|---|---|
| online | English online | German online |
| away | English away | German abwesend |
| busy | English busy | German beschäftigt |
| offline | English offline | German offline |
| more | English more | German weitere |
| people | English people | German 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
'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.