Avatar
Displays a user's profile image with an initials fallback when no image is available, commonly used in navigation bars, comment threads, and user lists.
Use Cases
- Display user profile pictures in navigation headers
- Show commenter avatars in discussion threads
- Represent team members in a project dashboard
- Display sender photos in chat or messaging interfaces
- Show participants in a video call lobby
- Indicate online/offline status alongside user identity
- Stack overlapping avatars to represent a group of users
Simplest Implementation
"use client";
interface AvatarProps {
src?: string;
name: string;
}
export function Avatar({ src, name }: AvatarProps) {
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return src ? (
<img
src={src}
alt={name}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-sm font-medium text-white">
{initials}
</div>
);
}Renders an image when src is provided, otherwise falls back to initials extracted from the user's name. The object-cover class prevents image distortion on non-square photos.
Variations
Image Avatar with Fallback on Error
"use client";
import { useState } from "react";
interface AvatarProps {
src?: string;
name: string;
}
export function Avatar({ src, name }: AvatarProps) {
const [failed, setFailed] = useState(false);
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
if (!src || failed) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-sm font-medium text-white">
{initials}
</div>
);
}
return (
<img
src={src}
alt={name}
onError={() => setFailed(true)}
className="h-10 w-10 rounded-full object-cover"
/>
);
}Gracefully handles broken image URLs by catching the onError event and falling back to initials. Without this, a broken image shows the browser's default broken icon.
Initials with Color Variants
"use client";
interface AvatarProps {
name: string;
}
const colors = [
"bg-red-600",
"bg-orange-600",
"bg-amber-600",
"bg-green-600",
"bg-teal-600",
"bg-blue-600",
"bg-indigo-600",
"bg-purple-600",
"bg-pink-600",
];
function getColorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
export function Avatar({ name }: AvatarProps) {
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<div
className={`flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium text-white ${getColorFromName(name)}`}
>
{initials}
</div>
);
}Assigns a consistent background color based on a hash of the user's name. The same name always produces the same color, making it easier to visually distinguish users in a list.
With Status Indicator
"use client";
type Status = "online" | "offline" | "busy" | "away";
interface AvatarProps {
src?: string;
name: string;
status?: Status;
}
const statusColors: Record<Status, string> = {
online: "bg-green-500",
offline: "bg-gray-400",
busy: "bg-red-500",
away: "bg-yellow-500",
};
export function Avatar({ src, name, status }: AvatarProps) {
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<div className="relative inline-flex">
{src ? (
<img src={src} alt={name} className="h-10 w-10 rounded-full object-cover" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-sm font-medium text-white">
{initials}
</div>
)}
{status && (
<span
className={`absolute bottom-0 right-0 h-3 w-3 rounded-full ring-2 ring-white ${statusColors[status]}`}
aria-label={status}
/>
)}
</div>
);
}A colored dot positioned at the bottom-right corner indicates the user's status. The ring-2 ring-white creates a white border around the dot so it stands out against the avatar image.
Size Variants
"use client";
type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
interface AvatarProps {
src?: string;
name: string;
size?: AvatarSize;
}
const sizeClasses: Record<AvatarSize, { container: string; text: string }> = {
xs: { container: "h-6 w-6", text: "text-[10px]" },
sm: { container: "h-8 w-8", text: "text-xs" },
md: { container: "h-10 w-10", text: "text-sm" },
lg: { container: "h-14 w-14", text: "text-lg" },
xl: { container: "h-20 w-20", text: "text-2xl" },
};
export function Avatar({ src, name, size = "md" }: AvatarProps) {
const sizes = sizeClasses[size];
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return src ? (
<img src={src} alt={name} className={`rounded-full object-cover ${sizes.container}`} />
) : (
<div
className={`flex items-center justify-center rounded-full bg-blue-600 font-medium text-white ${sizes.container} ${sizes.text}`}
>
{initials}
</div>
);
}Five size presets from xs (24px) to xl (80px). The font size scales proportionally with the container so initials remain readable at every size.
Avatar Group / Stack
"use client";
interface AvatarGroupProps {
users: { name: string; src?: string }[];
max?: number;
}
export function AvatarGroup({ users, max = 4 }: AvatarGroupProps) {
const visible = users.slice(0, max);
const remaining = users.length - max;
return (
<div className="flex -space-x-2">
{visible.map((user) => {
const initials = user.name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return user.src ? (
<img
key={user.name}
src={user.src}
alt={user.name}
className="h-8 w-8 rounded-full border-2 border-white object-cover"
/>
) : (
<div
key={user.name}
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-white bg-blue-600 text-xs font-medium text-white"
>
{initials}
</div>
);
})}
{remaining > 0 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-white bg-gray-200 text-xs font-medium text-gray-600">
+{remaining}
</div>
)}
</div>
);
}Uses -space-x-2 to overlap avatars horizontally. A border-2 border-white on each avatar creates visual separation. When the user count exceeds max, a "+N" badge shows the overflow count.
With Badge
"use client";
interface AvatarProps {
src?: string;
name: string;
badge?: string | number;
}
export function Avatar({ src, name, badge }: AvatarProps) {
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<div className="relative inline-flex">
{src ? (
<img src={src} alt={name} className="h-10 w-10 rounded-full object-cover" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-sm font-medium text-white">
{initials}
</div>
)}
{badge !== undefined && (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white ring-2 ring-white">
{badge}
</span>
)}
</div>
);
}A notification badge positioned at the top-right corner. The min-w-5 ensures the badge stays circular for single digits while expanding for larger numbers. The ring-2 ring-white separates the badge from the avatar visually.
Complex Implementation
"use client";
import { forwardRef, useState, useMemo } from "react";
import Image from "next/image";
type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
type Status = "online" | "offline" | "busy" | "away";
interface AvatarProps {
src?: string;
name: string;
size?: AvatarSize;
status?: Status;
badge?: string | number;
rounded?: "full" | "lg";
className?: string;
}
const sizeConfig: Record<AvatarSize, { px: number; container: string; text: string; status: string; badge: string }> = {
xs: { px: 24, container: "h-6 w-6", text: "text-[10px]", status: "h-2 w-2 ring-1", badge: "h-3.5 min-w-3.5 text-[8px] -right-0.5 -top-0.5" },
sm: { px: 32, container: "h-8 w-8", text: "text-xs", status: "h-2.5 w-2.5 ring-2", badge: "h-4 min-w-4 text-[9px] -right-1 -top-1" },
md: { px: 40, container: "h-10 w-10", text: "text-sm", status: "h-3 w-3 ring-2", badge: "h-5 min-w-5 text-[10px] -right-1 -top-1" },
lg: { px: 56, container: "h-14 w-14", text: "text-lg", status: "h-3.5 w-3.5 ring-2", badge: "h-5 min-w-5 text-[10px] -right-0.5 -top-0.5" },
xl: { px: 80, container: "h-20 w-20", text: "text-2xl", status: "h-4 w-4 ring-2", badge: "h-6 min-w-6 text-xs -right-1 -top-1" },
};
const statusColors: Record<Status, string> = {
online: "bg-green-500",
offline: "bg-gray-400",
busy: "bg-red-500",
away: "bg-yellow-500",
};
const bgColors = [
"bg-red-600", "bg-orange-600", "bg-amber-600", "bg-green-600",
"bg-teal-600", "bg-blue-600", "bg-indigo-600", "bg-purple-600", "bg-pink-600",
];
function hashName(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash);
}
export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(function Avatar(
{ src, name, size = "md", status, badge, rounded = "full", className },
ref
) {
const [imgFailed, setImgFailed] = useState(false);
const config = sizeConfig[size];
const roundedClass = rounded === "full" ? "rounded-full" : "rounded-lg";
const initials = useMemo(
() =>
name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase(),
[name]
);
const bgColor = useMemo(() => bgColors[hashName(name) % bgColors.length], [name]);
return (
<div ref={ref} className={`relative inline-flex shrink-0 ${className ?? ""}`}>
{src && !imgFailed ? (
<Image
src={src}
alt={name}
width={config.px}
height={config.px}
onError={() => setImgFailed(true)}
className={`${config.container} ${roundedClass} object-cover`}
/>
) : (
<div
className={`flex items-center justify-center font-medium text-white ${config.container} ${config.text} ${roundedClass} ${bgColor}`}
role="img"
aria-label={name}
>
{initials}
</div>
)}
{status && (
<span
className={`absolute bottom-0 right-0 ${roundedClass === "rounded-full" ? "rounded-full" : "rounded-full"} ring-white ${config.status} ${statusColors[status]}`}
aria-label={`Status: ${status}`}
/>
)}
{badge !== undefined && (
<span
className={`absolute flex items-center justify-center rounded-full bg-red-500 px-0.5 font-bold text-white ring-2 ring-white ${config.badge}`}
>
{typeof badge === "number" && badge > 99 ? "99+" : badge}
</span>
)}
</div>
);
});
// --- Avatar Group ---
interface AvatarGroupProps {
users: { name: string; src?: string }[];
max?: number;
size?: AvatarSize;
}
export function AvatarGroup({ users, max = 4, size = "sm" }: AvatarGroupProps) {
const visible = users.slice(0, max);
const remaining = users.length - max;
const config = sizeConfig[size];
return (
<div className="flex -space-x-2" role="group" aria-label={`${users.length} users`}>
{visible.map((user) => (
<Avatar
key={user.name}
src={user.src}
name={user.name}
size={size}
className="border-2 border-white"
/>
))}
{remaining > 0 && (
<div
className={`flex items-center justify-center rounded-full border-2 border-white bg-gray-200 font-medium text-gray-600 ${config.container} ${config.text}`}
>
+{remaining}
</div>
)}
</div>
);
}Key aspects:
- Next.js Image optimization -- uses
next/imagefor automatic resizing, lazy loading, and format conversion. Falls back to initials on error. - Deterministic color hashing -- the same user name always produces the same background color, providing visual consistency across the application without storing color preferences.
- Scaled indicators -- status dots and badge sizes are proportional to the avatar size, so everything looks correct at
xsthroughxl. - forwardRef -- allows parent components to measure, position, or animate the avatar with refs.
- Badge overflow -- numbers above 99 are capped to "99+" to prevent the badge from growing too wide and breaking the layout.
- role="img" on fallback -- the initials div is marked as an image role with
aria-labelso screen readers announce the user's full name rather than reading individual letters. - shrink-0 -- prevents the avatar from shrinking inside flex containers, which is a common layout issue in sidebars and user lists.
Gotchas
-
Missing
alton images -- Omitting thealtattribute makes the avatar invisible to screen readers. Always setaltto the user's name. -
Image aspect ratio distortion -- Non-square profile images stretch without
object-cover. Always useobject-coveron rounded avatar images. -
Initials from empty strings --
"".split(" ")returns[""], and""[0]isundefined. Guard against empty or undefined names before extracting initials. -
Status dot invisible on dark backgrounds -- A green "online" dot disappears against a green avatar. The
ring-whiteborder is essential for contrast in all color combinations. -
Layout shift from image loading -- Without explicit width and height, the avatar collapses to 0px until the image loads. Always set fixed dimensions on the container.
-
Avatar group z-index stacking -- Overlapping avatars may render in the wrong order. Add
hover:z-10if you want hovered avatars to pop above their neighbors.