Loading…
Loading…
Represents a user or entity with an image, icon, or initials.
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:
This fallback chain — image → initials → icon — is the hallmark of a robust avatar implementation.
When to use an Avatar:
When NOT to use an Avatar:
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.
| 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 | 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 | 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 |
| 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 |
| 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) |
| 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 |
A robust initials algorithm:
xs size, 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 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.
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. |
Avatars should implement a deliberate loading sequence:
opacity: 0 → 1 over 200ms).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.
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):
alt text: alt="John Doe" or alt="John Doe's profile photo". Never alt="avatar" or alt="user".aria-label="John Doe" on the container. Screen readers should announce the name, not the letters "J D."alt="" or aria-hidden="true" to hide it from screen readers. The visible name already conveys the identity.aria-label="View John Doe's profile" or wrap in an <a> with text.Decorative vs. Informational (WCAG 1.1.1):
[avatar] John Doe), the avatar is decorative — hide it with aria-hidden="true" or empty alt.Status Indicators (WCAG 1.3.1, 1.4.1):
<span class="sr-only">Online</span> inside the avatar container, or use aria-label on the status dot.Avatar Groups (WCAG 1.3.1):
role="group" and aria-label="Team members" or similar.aria-label="3 more members".Focus Visibility (WCAG 2.4.7 / 2.4.13):
Target Size (WCAG 2.5.8):
xs size (24px) is the bare minimum — consider using sm (32px) or larger for interactive avatars.Non-Text Contrast (WCAG 1.4.11):
loading="lazy" attribute on avatar images in long lists to defer off-screen image loading.object-fit: cover on the <img> element so images fill the circle without distortion.xs (24px) is acceptable for display but too small for reliable clicking. Use sm (32px) minimum for interactive avatars.z-index incrementally so later avatars render on top of earlier ones.<!-- 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>
);
}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.