Avatar
A face over its own initials, that fades up once the photo has decoded.
The idea
The initials are always there underneath, and the photo is laid over them at nothing until it has actually loaded — so a slow image shows a calm monogram, and a broken one never shows its torn-page icon. In a group the faces overlap, each cut from the last by a ring of the page colour rather than a grey border, and the row ends on a face-sized count that names who it stands for. The status dot is ink; the word says which status.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Avatar, AvatarGroup } from "mihirmodi-ui";
// Initials underneath; the photo fades up once it has decoded,
// and a broken URL simply stays initials.
<Avatar src="/ada.webp" name="Ada Byron" size="lg" status="Online" />
// On a grey surface, cut the rings in that grey.
<AvatarGroup label="Editors" max={4} ring="var(--color-background-200)">
{people.map((p) => (
<Avatar key={p.id} name={p.name} src={p.photo} />
))}
</AvatarGroup>Props
| Prop | Type | Notes |
|---|---|---|
| namestring | string | Initials and the accessible name. |
| srcstring | string | The photo. Optional. |
| size"xs" | "sm" | "md" | "lg" | "xs" | "sm" | "md" | "lg"= "md" | 20, 28, 36 and 48px. |
| statusstring | string | Draws the ink dot; the string is what a screen reader hears. |
| ringstring | string= var(--color-background-100) | The colour cut round the dot and between faces — the surface it sits on. |
| AvatarGroup maxnumber | number= 4 | Faces shown before the +N count. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { Children, isValidElement, useCallback, useState, type CSSProperties, type ReactElement, type ReactNode } from "react";
export type AvatarSize = "xs" | "sm" | "md" | "lg";
export type AvatarProps = {
/** The photo. Optional — without it, or until it loads, the initials stand in. */
src?: string;
/** The person's name. Their initials, and the image's alt text. */
name: string;
size?: AvatarSize;
/**
* A dot on the lower right — present, away, on a call. It is ink, not green:
* pass the word as the value and it is what a screen reader hears.
*/
status?: string;
/**
* The ring between the face and its dot (and between faces in a group):
* the colour of whatever the avatar sits on. Default the page background.
*/
ring?: string;
className?: string;
};
const BOX: Record<AvatarSize, { px: number; text: string; dot: number }> = {
xs: { px: 20, text: "text-[8.5px]", dot: 6 },
sm: { px: 28, text: "text-[10.5px]", dot: 7 },
md: { px: 36, text: "text-[12px]", dot: 9 },
lg: { px: 48, text: "text-[15px]", dot: 11 },
};
/** First letter of the first and last words — "Ada Lovelace" → "AL", "Ada" → "A". */
function initials(name: string) {
const words = name.trim().split(/\s+/).filter(Boolean);
if (!words.length) return "";
const first = words[0][0] ?? "";
const last = words.length > 1 ? (words[words.length - 1][0] ?? "") : "";
return (first + last).toUpperCase();
}
/**
* A person, as a face or as their initials.
*
* The initials are always there, underneath; the photo is laid over them at
* nothing and fades up once it has actually decoded. So a slow image shows a
* calm monogram rather than an empty circle, and a broken one never shows its
* torn-page icon — it simply stays initials. A cached image that finished
* before React hydrated is caught as it mounts, so it does not wait for a load
* event that already happened.
*
* Initials are set at 500 with a touch of positive tracking: at 10–12px two
* capitals merge without it. The status dot is ink with a ring of the page
* colour cut around it, so it reads as sitting on the face rather than as a
* bite out of it — and it says *that* there is a status; the word says which.
*/
export function Avatar({ src, name, size = "md", status, ring = "var(--color-background-100)", className = "" }: AvatarProps) {
// Keyed by the URL, so swapping `src` fades the new photo in rather than
// inheriting the old one's "loaded".
const [loaded, setLoaded] = useState<string | null>(null);
const [broken, setBroken] = useState<string | null>(null);
const shown = !!src && loaded === src;
const failed = !!src && broken === src;
const box = BOX[size];
// A ref callback rather than an effect: it runs as the node attaches, which
// is the moment to ask whether the browser already has the pixels.
const probe = useCallback(
(img: HTMLImageElement | null) => {
if (img && src && img.complete && img.naturalWidth > 0) setLoaded(src);
},
[src]
);
return (
<span
className={`relative inline-flex shrink-0 select-none ${className}`}
style={{ width: box.px, height: box.px }}
role="img"
aria-label={status ? `${name}, ${status}` : name}
>
<span
aria-hidden
className={`relative flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-gray-100 font-medium leading-none tracking-[0.02em] text-muted shadow-[inset_0_0_0_0.5px_var(--color-gray-alpha-400)] ${box.text}`}
>
{initials(name)}
{src && !failed && (
<img
key={src}
ref={probe}
src={src}
alt=""
decoding="async"
draggable={false}
onLoad={() => setLoaded(src)}
onError={() => setBroken(src)}
className={`absolute inset-0 h-full w-full rounded-full object-cover transition-opacity duration-200 ease-out motion-reduce:transition-none ${
shown ? "opacity-100" : "opacity-0"
}`}
/>
)}
</span>
{status && (
<span
aria-hidden
className="absolute bottom-0 right-0 rounded-full bg-ink"
style={{ width: box.dot, height: box.dot, boxShadow: `0 0 0 2px ${ring}` }}
/>
)}
</span>
);
}
export type AvatarGroupProps = {
/** `Avatar`s. Their size is taken from the group's, so set it once here. */
children: ReactNode;
/** Show this many faces, then a "+N" for the rest. */
max?: number;
size?: AvatarSize;
/** The colour of the surface the group sits on — it is cut between the faces. */
ring?: string;
/** The group's accessible name, e.g. "Editors". */
label?: string;
className?: string;
};
/* A fifth of the face, no more: two initials span over half a small face, and a
deeper overlap (plus the 2px ring) cuts into the first letter. */
const OVERLAP: Record<AvatarSize, number> = { xs: 4, sm: 6, md: 7, lg: 9 };
/**
* A few people, overlapped, with a count for the rest.
*
* The faces overlap by about a fifth, each cut from the one before by a
* ring of the page colour — a ring, not a border, because a grey border would
* draw every face as a separate sticker. The count is a face-sized chip in the
* same grey as the initials, so the row ends on something that looks like one
* more person rather than a badge, and it names how many it stands for.
*/
export function AvatarGroup({ children, max = 4, size = "md", ring = "var(--color-background-100)", label, className = "" }: AvatarGroupProps) {
const all = Children.toArray(children).filter(isValidElement) as ReactElement<AvatarProps>[];
const shown = all.slice(0, max);
const rest = all.length - shown.length;
const box = BOX[size];
const cut: CSSProperties = { boxShadow: `0 0 0 2px ${ring}`, borderRadius: 9999 };
const hidden = all.slice(max).map((a) => a.props.name);
return (
<div role="group" aria-label={label} className={`flex items-center ${className}`}>
{shown.map((a, i) => (
<span key={a.key ?? i} className="relative inline-flex" style={{ ...cut, marginLeft: i ? -OVERLAP[size] : 0, zIndex: shown.length - i }}>
<Avatar {...a.props} size={size} ring={ring} />
</span>
))}
{rest > 0 && (
<span
className={`relative inline-flex items-center justify-center rounded-full bg-gray-100 font-medium leading-none tabular-nums text-muted ${box.text}`}
style={{ ...cut, boxShadow: `${cut.boxShadow}, inset 0 0 0 0.5px var(--color-gray-alpha-400)`, width: box.px, height: box.px, marginLeft: -OVERLAP[size] }}
role="img"
aria-label={`and ${rest} more: ${hidden.join(", ")}`}
title={hidden.join(", ")}
>
+{rest}
</span>
)}
</div>
);
}