Avatar
Displays a user profile image that degrades gracefully to coloured initials, with an optional presence dot.
Live preview and source code for Avatar
"use client"
import React from "react";
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
export type AvatarStatus = "online" | "away" | "busy" | "offline" | "none";
export interface AvatarProps {
/** Image URL. When omitted or failing to load, initials are shown instead */
src?: string;
/** Alt text for the image. Falls back to `name` */
alt?: string;
/** Full name — used for the alt text and to derive initials */
name?: string;
/** Overrides the initials derived from `name` */
fallback?: string;
/** Visual size of the avatar */
size?: AvatarSize;
/** Circle or rounded square */
shape?: "circle" | "square";
/** Presence dot rendered at the bottom-right */
status?: AvatarStatus;
/** Draws a soft ring around the avatar */
ring?: boolean;
/** Extra classes for consumers using a utility CSS framework */
className?: string;
style?: React.CSSProperties;
}
const sizeStyles: Record<AvatarSize, { box: number; font: number; dot: number; radius: number }> = {
xs: { box: 24, font: 10, dot: 7, radius: 7 },
sm: { box: 32, font: 12, dot: 9, radius: 9 },
md: { box: 40, font: 15, dot: 11, radius: 11 },
lg: { box: 56, font: 20, dot: 14, radius: 15 },
xl: { box: 80, font: 28, dot: 18, radius: 22 },
};
const statusColors: Record<Exclude<AvatarStatus, "none">, string> = {
online: "#22c55e",
away: "#f59e0b",
busy: "#ef4444",
offline: "#a1a1aa",
};
/** Deterministic pastel background per name, so the same person always
* gets the same colour instead of a random one on every render. */
const FALLBACK_PALETTE = [
{ bg: "#e0e7ff", fg: "#4338ca" },
{ bg: "#fce7f3", fg: "#be185d" },
{ bg: "#dcfce7", fg: "#15803d" },
{ bg: "#fef3c7", fg: "#b45309" },
{ bg: "#e0f2fe", fg: "#0369a1" },
{ bg: "#ede9fe", fg: "#6d28d9" },
];
function paletteFor(seed: string) {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
}
return FALLBACK_PALETTE[hash % FALLBACK_PALETTE.length];
}
function initialsFrom(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
/**
* Avatar — a profile image that degrades gracefully to coloured initials
* when no `src` is given or the image fails to load. Renders standalone
* with zero props.
*/
export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
(
{
src,
alt,
name = "Mayank Tomar",
fallback,
size = "lg",
shape = "circle",
status = "none",
ring = false,
className = "",
style,
},
ref
) => {
// Remember *which* src failed rather than a boolean. That resets itself
// when a new src arrives, without an effect that would race the img's own
// onError and flip the fallback back off on the very first render.
const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
// With SSR the browser starts loading the image before React hydrates, so
// a broken URL can fire `error` while no listener is attached yet. Catch
// that case on mount: a finished load with zero width means it failed.
const checkAlreadyFailed = React.useCallback((node: HTMLImageElement | null) => {
if (node?.complete && node.naturalWidth === 0) {
setFailedSrc(node.getAttribute("src"));
}
}, []);
const { box, font, dot, radius } = sizeStyles[size];
const palette = paletteFor(fallback || name);
const showImage = Boolean(src) && failedSrc !== src;
const borderRadius = shape === "circle" ? "9999px" : `${radius}px`;
return (
<span
ref={ref}
className={className}
style={{
position: "relative",
display: "inline-flex",
flexShrink: 0,
width: box,
height: box,
...style,
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
overflow: "hidden",
borderRadius,
background: showImage ? "#f4f4f5" : palette.bg,
color: palette.fg,
fontSize: font,
fontWeight: 600,
fontFamily: "inherit",
letterSpacing: "0.01em",
userSelect: "none",
boxShadow: ring ? "0 0 0 2px #ffffff, 0 0 0 4px rgba(0,0,0,0.08)" : "none",
}}
>
{showImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
ref={checkAlreadyFailed}
src={src}
alt={alt ?? name}
onError={() => setFailedSrc(src ?? null)}
style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
/>
) : (
<span aria-hidden={false}>{fallback || initialsFrom(name)}</span>
)}
</span>
{status !== "none" && (
<span
aria-label={status}
style={{
position: "absolute",
right: 0,
bottom: 0,
width: dot,
height: dot,
borderRadius: "9999px",
background: statusColors[status],
border: "2px solid #ffffff",
boxSizing: "content-box",
}}
/>
)}
</span>
);
}
);
Avatar.displayName = "Avatar";
export interface AvatarGroupProps {
/** People to render, left to right */
people?: Array<{ name: string; src?: string }>;
/** Anything beyond this count collapses into a +N chip */
max?: number;
size?: AvatarSize;
/** How much each avatar overlaps the previous one, as a fraction of its
* width. Keep at or below ~0.25 so initials aren't clipped. */
overlap?: number;
className?: string;
}
/** A row of overlapping avatars with a +N overflow chip. */
export function AvatarGroup({
people = [],
max = 4,
size = "md",
overlap = 0.25,
className = "",
}: AvatarGroupProps) {
const visible = people.slice(0, max);
const overflow = people.length - visible.length;
const { box, font } = sizeStyles[size];
const shift = -box * overlap;
return (
<div className={className} style={{ display: "inline-flex", alignItems: "center" }}>
{visible.map((person, i) => (
<span key={`${person.name}-${i}`} style={{ marginLeft: i === 0 ? 0 : shift }}>
<Avatar name={person.name} src={person.src} size={size} ring />
</span>
))}
{overflow > 0 && (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: box,
height: box,
marginLeft: shift,
borderRadius: "9999px",
background: "#18181b",
color: "#ffffff",
fontSize: font * 0.8,
fontWeight: 600,
fontFamily: "inherit",
boxShadow: "0 0 0 2px #ffffff",
}}
>
+{overflow}
</span>
)}
</div>
);
}
export default Avatar;
A profile picture that falls back to coloured initials when there is no image
or the image fails to load, with an optional presence dot and an AvatarGroup
for stacked user rows. The fallback colour is derived from the name, so the
same person keeps the same tint on every render instead of flickering between
random ones. It renders a <span> with inline styles and a plain <img>, with
no dependencies beyond React.
Installation
Copy the source from the Code tab above into your project as Avatar.tsx. It
imports nothing but React, so there is no install step, no CSS to load and no
config to change.
The file exports two things: Avatar as its default, and AvatarGroup beside
it. The group builds on the same component, so keep them in one file.
Usage
Every prop is optional. With no src, it renders initials derived from the
name:
import Avatar, { AvatarGroup } from "./components/Avatar";
export default function Example() {
return (
<>
<Avatar name="Ana Ruiz" src="/ana.jpg" status="online" />
<AvatarGroup people={[{ name: "Ana Ruiz" }, { name: "Dev Mehta" }]} max={4} />
</>
);
}When to reach for it
A comment thread, an activity feed or a table of members, where most rows have
a picture and some do not. The initials fallback means a missing src still
renders something deliberate rather than a broken image icon.
A presence list in a chat or dashboard sidebar. The status dot sits at the
bottom-right in four states: online, away, busy and offline.
Social proof in a marketing section, using AvatarGroup. It overlaps the first
max people and collapses the rest into a +N chip.
When not to
If it is clickable, wrap it rather than using it as the control. This
renders a <span> with no focus handling and no keyboard behaviour. Put it
inside a <button> or an <a> and let that element carry the label and the
focus ring.
If you are rendering hundreds of remote images, this is a bare <img> with
no lazy loading attribute and no next/image sizing. Copy the file and swap the
<img> for your framework's image component before you ship a long list.
If you need a member roster rather than a summary, AvatarGroup is the
wrong shape. It caps at max and hides everyone else behind a count that is
not interactive, which is a decoration on top of a real list, not a substitute
for one.
Accessibility
When an image renders, alt falls back to name, so a screen reader gets the
person rather than a filename. When the fallback renders instead, what is on
screen is the initials, and that is what gets read: "MT", not "Mayank Tomar".
If the avatar is the only thing identifying the person, put the name in text
beside it.
The presence dot carries an aria-label with the status word, but it is a
<span> with no role, and a label on a generic element is not reliably
announced. Treat the dot as decoration and put the state in text wherever
knowing it actually matters.
Props
Every prop the Avatar component accepts, with its type and default value.
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | Image URL. When omitted or failing to load, initials are shown instead. | |
alt | string | Alt text for the image. Falls back to name. | |
name | string | Mayank Tomar | Full name, used for alt text and to derive the initials. |
fallback | string | Overrides the initials derived from name. | |
size | "xs" | "sm" | "md" | "lg" | "xl" | lg | Visual size of the avatar. |
shape | "circle" | "square" | circle | Circle or rounded square. |
status | "online" | "away" | "busy" | "offline" | "none" | none | Presence dot rendered at the bottom-right. |
ring | boolean | false | Draws a soft ring around the avatar. |
Notes
The fallback colour is hashed from fallback || name, so overriding the
initials also changes the tint. Passing the same name anywhere in your app
always produces the same colour.
Initials come from the first and last word of name, so "Mayank Tomar" becomes
"MT" and a single word becomes its first two letters. An empty name renders
?.
className and style land on the outer wrapper, not on the circle that draws
the background, so a background passed through style sits behind the avatar
rather than replacing its fill.
AvatarGroup renders every avatar with ring on, which is what keeps the
overlap readable. Keep overlap at or below 0.25; above that the initials on
the covered avatars start to clip.


