Overview
The Avatar component represents a user, entity, or object through a visual indicator — typically a photo, an icon, or the user's initials. Avatars provide immediate visual identification in user lists, comments, navigation bars, chat interfaces, and anywhere a "who" needs to be communicated at a glance.
Avatars solve a fundamental recognition problem: humans process faces and visual symbols faster than reading names. A row of avatars in a comment thread or a team list conveys "who's involved" in milliseconds, while a list of text names requires sequential reading.
The component must handle three rendering states gracefully:
- Image available: Show the user's photo or uploaded image.
- No image, name available: Show initials derived from the user's name (e.g., "JD" for John Doe).
- No image, no name: Show a generic placeholder icon (typically a silhouette or person icon).
This fallback chain — image → initials → icon — is the hallmark of a robust avatar implementation.
When to use an Avatar:
- User profiles and account menus
- Comment threads, chat messages, activity feeds
- Team member lists, assignee indicators
- Author attribution on articles or posts
- Contact lists, email recipients
When NOT to use an Avatar:
- Representing non-human entities where a logo or icon is more appropriate — use an image or Badge
- Decorative illustrations unrelated to identity — use an image element directly
- When the identity context is already established (e.g., the user's own profile page title)
Avatar shape is a significant design decision. Circular avatars (border-radius: 50%) are the dominant convention (used by virtually every social platform), but square avatars with rounded corners are common in enterprise and workspace tools (Slack, Notion). Experiment with shapes using the Border Radius Generator. For non-rectangular avatar shapes (hexagons, squircles), explore the Clip Path Generator.
Variants
Content Variants
| Variant | Content | Fallback | Use Case |
|---|---|---|---|
| Image | User photo, uploaded image, Gravatar | Initials → Icon | Primary — when image is available |
| Initials | 1–2 letters derived from name | Icon | When no photo exists but name is known |
| Icon | Generic person silhouette or custom icon | — | Final fallback, anonymous users |
| Status | Any content variant + a status indicator dot (online, offline, busy, away) | — | Chat, collaboration, presence indicators |
Shape Variants
| Shape | CSS | Convention | Design With |
|---|---|---|---|
| Circle | border-radius: 50% | Social platforms, messaging apps, most consumer products | Default — no generator needed |
| Rounded Square | border-radius: var(--radius-md) | Slack, Notion, enterprise tools, workspace apps | Border Radius Generator |
| Squircle | clip-path or SVG | iOS-inspired, premium feel | Clip Path Generator |
| Hexagon | clip-path: polygon(...) | Gaming, creative platforms | Clip Path Generator |
Size Variants
| Size | Dimensions | Initials Font | Use Case |
|---|---|---|---|
| xs | 24×24px | 10px (1 letter) | Dense lists, inline mentions, table cells |
| sm | 32×32px | 12px | Comment threads, compact layouts |
| md | 40×40px | 14px | Default — navigation bars, cards, lists |
| lg | 56×56px | 18px | Profile headers, team grids |
| xl | 80×80px | 24px | Profile pages, large cards |
| 2xl | 120×120px | 32px | Profile edit, onboarding, hero sections |
Group Variants
| Pattern | Description | Use Case |
|---|---|---|
| Stacked | Overlapping avatars with a slight offset (margin-left: -8px). | "5 people are viewing" indicators, team preview |
| Stacked + Overflow | Stacked avatars with a "+3" overflow indicator at the end. | Limited space, showing partial team |
| Grid | Avatars in a uniform grid without overlap. | Team pages, user directories |
| Inline | Small avatar next to a name in running text. | Author bylines, "@mention" displays |
Properties
Avatar Properties
| Property | Type | Default | Description |
|---|---|---|---|
src | string | — | Image URL. Falls back to initials/icon if the image fails to load. |
alt | string | — | Alt text for the image. Required when src is provided. |
name | string | — | User's full name. Used to generate initials and as fallback alt text. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'md' | Avatar dimensions |
shape | 'circle' | 'square' | 'circle' | Border radius shape |
color | string | auto | Background color for initials variant. Auto-generates from name hash if not specified. |
status | 'online' | 'offline' | 'busy' | 'away' | — | Status indicator dot |
statusPosition | 'top-right' | 'bottom-right' | 'bottom-right' | Position of the status indicator |
icon | ReactNode | default person icon | Custom fallback icon when no image or name |
bordered | boolean | false | Adds a white border ring (useful in stacked groups) |
onClick | () => void | — | Makes the avatar interactive (e.g., open profile) |
Avatar Group Properties
| Property | Type | Default | Description |
|---|---|---|---|
max | number | — | Maximum avatars to show before "+N" overflow |
size | Size | 'md' | Uniform size for all avatars in the group |
spacing | number | -8 | Overlap offset in pixels (negative = overlap) |
children | ReactNode | — | <Avatar> children |
Initials Generation Logic
A robust initials algorithm:
- Split name by spaces, hyphens, or periods.
- Take the first letter of the first part and the first letter of the last part.
- If only one part (mononym), use the first letter only.
- Uppercase the result.
- For
xssize, show only 1 initial (space is too tight for 2).
Examples: "John Doe" → "JD", "Alice" → "A", "Jean-Luc Picard" → "JP", "María García López" → "ML"
Token Mappings
Design Token Mappings
| Token Category | Token Example | Avatar Usage |
|---|---|---|
| Color – Background | Generated from name hash or --color-primary-100 | Initials variant background |
| Color – Text | --color-primary-800 or white | Initials text color (contrast-safe against background) |
| Color – Fallback BG | --color-neutral-200 | Icon fallback variant background |
| Color – Fallback Icon | --color-neutral-500 | Placeholder person icon color |
| Color – Border | --color-white or --color-neutral-200 | Ring border for stacked groups |
| Color – Status Online | --color-success-500 | Green dot for online status |
| Color – Status Busy | --color-error-500 | Red dot for busy/do-not-disturb |
| Color – Status Away | --color-warning-500 | Yellow/amber dot for away |
| Color – Status Offline | --color-neutral-400 | Gray dot for offline |
| Border Radius | 50% or --radius-md | Circle vs. rounded square shape. Preview with Border Radius Generator. |
| Border Width | --border-width-2 (2px) | Group overlap border ring |
| Shadow | --shadow-sm | Hover elevation on interactive avatars |
| Typography | Scaled per size (10px–32px), --font-weight-semibold | Initials text |
| Transition | --duration-fast (150ms) | Hover scale and shadow transitions |
| Z-Index | Incremental per avatar in a stacked group | Ensures correct overlap order |
For non-standard avatar shapes (squircle, hexagon), tokens don't apply — use clip-path values from the Clip Path Generator instead of border-radius.
States
Interactive States
Avatars are often non-interactive (purely informational), but when clickable (opening a profile, selecting a user), they need proper state handling.
| State | Visual Treatment | Behavior |
|---|---|---|
| Default | Static image/initials/icon at natural size. | Informational — no interaction. |
| Hover (interactive) | Slight scale up (transform: scale(1.05)), subtle shadow, optional overlay (camera icon for editable). Cursor: pointer. | Indicates the avatar is clickable. |
| Focus-Visible | Focus ring around the avatar (circle or square matching the shape). | Keyboard navigation indicator. Must meet WCAG 2.4.13. |
| Active / Pressed | Scale down slightly (transform: scale(0.95)). | Click feedback. |
| Loading | Skeleton placeholder matching avatar shape and size, with shimmer animation. | Image is still loading. |
| Error (image) | Falls back to initials or icon. No broken image icon. | Image URL failed to load. |
| Selected | Checkmark overlay or ring highlight (in multi-select contexts like team assignment). | User has been selected from a list. |
| Disabled | Grayscale filter or reduced opacity. No interaction. | Avatar is not interactive in this context. |
Image Loading Strategy
Avatars should implement a deliberate loading sequence:
- Immediately render the initials/icon fallback (instant, no layout shift).
- Begin loading the image asynchronously.
- When the image loads, fade it in over the fallback (
opacity: 0→1over 200ms). - If the image fails (
onError), keep showing the fallback — never show the browser's broken image icon.
This approach eliminates both layout shift (CLS) and the jarring appearance of broken images.
Accessibility
Accessibility Requirements
Avatars are deceptively simple but have important accessibility considerations, especially around alternative text, status indicators, and interactive behavior.
Alternative Text (WCAG 1.1.1 Non-text Content):
- Image avatars: Must have meaningful
alttext:alt="John Doe"oralt="John Doe's profile photo". Neveralt="avatar"oralt="user". - Initials avatars: The initials themselves are visual — add
aria-label="John Doe"on the container. Screen readers should announce the name, not the letters "J D." - Icon fallback: If the avatar is purely decorative (next to a visible name), use
alt=""oraria-hidden="true"to hide it from screen readers. The visible name already conveys the identity. - Interactive avatars (links/buttons): The accessible name must describe the action:
aria-label="View John Doe's profile"or wrap in an<a>with text.
Decorative vs. Informational (WCAG 1.1.1):
- If the avatar appears next to a visible name (
[avatar] John Doe), the avatar is decorative — hide it witharia-hidden="true"or emptyalt. - If the avatar is the only identifier (e.g., stacked group without visible names), it must have an accessible name.
Status Indicators (WCAG 1.3.1, 1.4.1):
- Status dots (online/offline/busy) use color alone to convey state — this violates WCAG 1.4.1 if no text alternative is provided.
- Add a visually-hidden text label:
<span class="sr-only">Online</span>inside the avatar container, or usearia-labelon the status dot. - The status dot must have ≥3:1 contrast against both the avatar background AND the page background (it sits at the edge). Verify with Contrast Checker.
Avatar Groups (WCAG 1.3.1):
- Wrap the group in a container with
role="group"andaria-label="Team members"or similar. - The "+3" overflow indicator must be accessible:
aria-label="3 more members". - Each avatar in the group follows the same alt text rules as standalone avatars.
Focus Visibility (WCAG 2.4.7 / 2.4.13):
- Interactive avatars must show a visible focus ring.
- For circular avatars, use a circular focus ring (or a square ring with enough offset to be clearly visible around the circle).
- The focus ring must have ≥3:1 contrast against the surrounding background.
Target Size (WCAG 2.5.8):
- Interactive avatars must meet the 24×24px minimum. The
xssize (24px) is the bare minimum — consider usingsm(32px) or larger for interactive avatars. - In stacked groups, ensure each avatar's clickable area doesn't overlap with adjacent avatars' clickable areas.
Non-Text Contrast (WCAG 1.4.11):
- The avatar's border (or the shape boundary itself) must have ≥3:1 contrast against the page background. This is especially important for initials avatars with light background colors.
- Use the Contrast Checker to verify initials text against the avatar background color.
Usage Guidelines
Do's
- Implement the full fallback chain: image → initials → icon. Never show a broken image or empty circle.
- Generate consistent initials colors from a hash of the user's name or ID. The same person should always get the same color across sessions.
- Use the
loading="lazy"attribute on avatar images in long lists to defer off-screen image loading. - Apply
object-fit: coveron the<img>element so images fill the circle without distortion. - Show a skeleton placeholder while images load — matching the avatar's shape and size. This prevents layout shift.
- Add a white border ring to stacked/overlapping avatars so they're visually distinguishable against each other.
Don'ts
- Don't use avatars as the only identifier — always pair with a visible name in contexts where identification matters (comment threads, assignee lists). The avatar is a visual aid, not a replacement for text.
- Don't crop important content — circular avatars crop corners. Ensure the face or key content is centered.
- Don't use random colors for initials — compute deterministic colors from the user's name/ID so the color is consistent across the app.
- Don't use tiny avatars for interaction —
xs(24px) is acceptable for display but too small for reliable clicking. Usesm(32px) minimum for interactive avatars. - Don't auto-load large images — avatar images should be thumbnails (80×80px to 200×200px source). Loading a 4000×4000px profile photo for a 40px avatar wastes bandwidth.
- Don't forget the status dot positioning — when the avatar shape is square, the dot position changes. It should always sit at the corner of the shape, not float in space.
Stacked Group Best Practices
- Show a maximum of 3–5 avatars before the "+N" overflow. More than 5 stacked avatars becomes visually noisy.
- Apply
z-indexincrementally so later avatars render on top of earlier ones. - The "+N" overflow circle should use a neutral background with clear text contrast.
- Clicking the "+N" should expand the full list or link to a team/members page.
- Ensure the group doesn't overflow its container — the negative margins of stacked avatars can cause unexpected layout issues.
Code Snippets
<!-- Avatar: Image with Status -->
<div class="avatar avatar--md" role="img" aria-label="Jane Smith — Online">
<img
src="/avatars/jane-smith.jpg"
alt=""
class="avatar__image"
loading="lazy"
/>
<span class="avatar__status avatar__status--online" aria-hidden="true"></span>
</div>
<!-- Avatar: Initials Fallback -->
<div class="avatar avatar--md avatar--initials" aria-label="John Doe" style="background-color: #6366F1;">
<span class="avatar__initials" aria-hidden="true">JD</span>
</div>
<!-- Avatar: Icon Fallback -->
<div class="avatar avatar--md avatar--icon" aria-label="Unknown user">
<svg class="avatar__icon-svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
</div>
<!-- Avatar Group: Stacked -->
<div class="avatar-group" role="group" aria-label="Team members">
<div class="avatar avatar--sm avatar--initials" aria-label="Alice" style="background-color: #EF4444; z-index: 4;">
<span class="avatar__initials" aria-hidden="true">A</span>
</div>
<div class="avatar avatar--sm avatar--initials" aria-label="Bob" style="background-color: #3B82F6; z-index: 3;">
<span class="avatar__initials" aria-hidden="true">B</span>
</div>
<div class="avatar avatar--sm avatar--initials" aria-label="Carol" style="background-color: #22C55E; z-index: 2;">
<span class="avatar__initials" aria-hidden="true">C</span>
</div>
<div class="avatar avatar--sm avatar--overflow" aria-label="3 more members" style="z-index: 1;">
<span aria-hidden="true">+3</span>
</div>
</div>
<style>
.avatar {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
vertical-align: middle;
}
.avatar--xs { width: 24px; height: 24px; }
.avatar--sm { width: 32px; height: 32px; }
.avatar--md { width: 40px; height: 40px; }
.avatar--lg { width: 56px; height: 56px; }
.avatar--xl { width: 80px; height: 80px; }
.avatar__image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.avatar--initials {
color: white;
font-weight: 600;
}
.avatar--sm .avatar__initials { font-size: 12px; }
.avatar--md .avatar__initials { font-size: 14px; }
.avatar--lg .avatar__initials { font-size: 18px; }
.avatar--icon {
background: var(--color-neutral-200);
color: var(--color-neutral-500);
}
.avatar__icon-svg {
width: 60%;
height: 60%;
}
.avatar__status {
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--color-white);
}
.avatar__status--online { background: var(--color-success-500, #22C55E); }
.avatar__status--offline { background: var(--color-neutral-400, #9CA3AF); }
.avatar__status--busy { background: var(--color-error-500, #EF4444); }
.avatar__status--away { background: var(--color-warning-500, #F59E0B); }
/* Avatar Group */
.avatar-group {
display: flex;
align-items: center;
}
.avatar-group .avatar {
border: 2px solid var(--color-white);
margin-left: -8px;
}
.avatar-group .avatar:first-child {
margin-left: 0;
}
.avatar--overflow {
background: var(--color-neutral-200);
color: var(--color-neutral-700);
font-size: 11px;
font-weight: 600;
}
/* Interactive avatar */
.avatar[role="button"],
a .avatar {
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease;
}
.avatar[role="button"]:hover,
a:hover .avatar {
transform: scale(1.05);
box-shadow: var(--shadow-sm);
}
.avatar[role="button"]:focus-visible,
a:focus-visible .avatar {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
</style>import React, { useState, useMemo, useId } from "react";
type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
type AvatarStatus = "online" | "offline" | "busy" | "away";
interface AvatarProps {
src?: string;
alt?: string;
name?: string;
size?: AvatarSize;
shape?: "circle" | "square";
status?: AvatarStatus;
bordered?: boolean;
onClick?: () => void;
}
const SIZE_MAP: Record<AvatarSize, number> = {
xs: 24, sm: 32, md: 40, lg: 56, xl: 80, "2xl": 120,
};
const FONT_MAP: Record<AvatarSize, number> = {
xs: 10, sm: 12, md: 14, lg: 18, xl: 24, "2xl": 32,
};
const STATUS_COLORS: Record<AvatarStatus, string> = {
online: "var(--color-success-500, #22C55E)",
offline: "var(--color-neutral-400, #9CA3AF)",
busy: "var(--color-error-500, #EF4444)",
away: "var(--color-warning-500, #F59E0B)",
};
const STATUS_LABELS: Record<AvatarStatus, string> = {
online: "Online",
offline: "Offline",
busy: "Busy",
away: "Away",
};
function getInitials(name: string): string {
const parts = name.split(/[\s\-.]/).filter(Boolean);
if (parts.length === 0) return "";
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function hashColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colors = [
"#EF4444", "#F97316", "#EAB308", "#22C55E",
"#3B82F6", "#6366F1", "#A855F7", "#EC4899",
"#14B8A6", "#64748B",
];
return colors[Math.abs(hash) % colors.length];
}
export function Avatar({
src,
alt,
name,
size = "md",
shape = "circle",
status,
bordered = false,
onClick,
}: AvatarProps) {
const [imgError, setImgError] = useState(false);
const px = SIZE_MAP[size];
const fontSize = FONT_MAP[size];
const radius = shape === "circle" ? "50%" : "var(--radius-md, 8px)";
const initials = name ? getInitials(name) : "";
const bgColor = name ? hashColor(name) : "var(--color-neutral-200)";
const showImage = src && !imgError;
const statusDotSize = Math.max(8, px * 0.25);
const accessibleName = useMemo(() => {
const parts: string[] = [];
if (name) parts.push(name);
else if (alt) parts.push(alt);
else parts.push("Unknown user");
if (status) parts.push(STATUS_LABELS[status]);
return parts.join(" — ");
}, [name, alt, status]);
const Tag = onClick ? "button" : "div";
return (
<Tag
type={onClick ? "button" : undefined}
onClick={onClick}
aria-label={accessibleName}
role={onClick ? undefined : "img"}
style={{
position: "relative",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: px,
height: px,
borderRadius: radius,
overflow: "visible",
flexShrink: 0,
backgroundColor: showImage ? "var(--color-neutral-100)" : bgColor,
color: showImage ? undefined : name ? "white" : "var(--color-neutral-500)",
fontWeight: 600,
fontSize,
border: bordered ? "2px solid var(--color-white)" : "none",
cursor: onClick ? "pointer" : "default",
padding: 0,
background: showImage ? "var(--color-neutral-100)" : bgColor,
}}
>
{showImage ? (
<img
src={src}
alt=""
aria-hidden="true"
onError={() => setImgError(true)}
loading="lazy"
style={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: radius,
}}
/>
) : initials ? (
<span aria-hidden="true">{size === "xs" ? initials[0] : initials}</span>
) : (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style={{ width: "60%", height: "60%" }}>
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
</svg>
)}
{status && (
<span
aria-hidden="true"
style={{
position: "absolute",
bottom: 0,
right: 0,
width: statusDotSize,
height: statusDotSize,
borderRadius: "50%",
backgroundColor: STATUS_COLORS[status],
border: "2px solid var(--color-white)",
}}
/>
)}
</Tag>
);
}
interface AvatarGroupProps {
max?: number;
size?: AvatarSize;
spacing?: number;
children: React.ReactNode;
}
export function AvatarGroup({ max, size = "md", spacing = -8, children }: AvatarGroupProps) {
const items = React.Children.toArray(children);
const visible = max ? items.slice(0, max) : items;
const overflow = max ? items.length - max : 0;
const px = SIZE_MAP[size];
return (
<div role="group" aria-label="User group" style={{ display: "flex", alignItems: "center" }}>
{visible.map((child, i) => (
<div
key={i}
style={{
marginLeft: i === 0 ? 0 : spacing,
zIndex: visible.length - i,
position: "relative",
}}
>
{React.isValidElement(child)
? React.cloneElement(child as React.ReactElement<AvatarProps>, { size, bordered: true })
: child}
</div>
))}
{overflow > 0 && (
<div
aria-label={`${overflow} more member${overflow !== 1 ? "s" : ""}`}
style={{
marginLeft: spacing,
width: px,
height: px,
borderRadius: "50%",
backgroundColor: "var(--color-neutral-200)",
color: "var(--color-neutral-700)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: px * 0.3,
fontWeight: 600,
border: "2px solid var(--color-white)",
position: "relative",
zIndex: 0,
}}
>
+{overflow}
</div>
)}
</div>
);
}Design Systems
Design System Implementations
Material Design 3 uses circular avatars exclusively. MUI's Avatar supports src (image), children (initials or icon), and automatic fallback. It integrates with AvatarGroup which supports max (overflow count), spacing (overlap adjustment), and renderSurplus for custom overflow rendering. Material's avatar uses a bgcolor prop for initials background and follows the 40px default size. The image loads with a subtle fade-in.
Ant Design provides Avatar with shape="circle|square", size (number or "small"|"default"|"large"), src with onError fallback, srcSet for responsive images, and icon for icon fallback. Ant's Avatar.Group shows a "+N" overflow avatar and supports maxCount, maxStyle, and maxPopoverPlacement (hovering the "+N" shows a popover with the remaining avatars). The group overflow popover is a thoughtful addition that many design systems lack.
Radix UI does not provide an Avatar primitive with built-in fallback logic. Developers use @radix-ui/react-avatar which provides Root, Image, and Fallback sub-components. The Image component renders only after successful load, and Fallback renders immediately (or after a configurable delayMs) if the image fails. This declarative fallback pattern is elegant — no onError state management needed.
Chakra UI provides Avatar with name (auto-generates initials and deterministic background color from a hash), src, size (2xs through 2xl), showBorder, and a child AvatarBadge for status indicators. Chakra's AvatarGroup supports max and spacing. The deterministic color generation from the name string is a particularly good pattern — the same name always produces the same color without any configuration.
Headless UI does not provide an avatar component — avatars are considered application-level rather than primitive widgets.
shadcn/ui offers an Avatar using Radix primitives with Tailwind styling: Avatar, AvatarImage, AvatarFallback. The fallback accepts any content (text, icon, skeleton). This composable pattern gives maximum flexibility while maintaining the declarative fallback behavior.
For experimenting with avatar border-radius (square with custom rounding), use the Border Radius Generator. For non-rectangular avatar shapes (squircle, hexagonal, shield), the Clip Path Generator provides the CSS clip-path values needed.