Avatar
A person or entity reduced to one glyph. Image, initials, fallback order, presence, and stacked groups.
Also called Profile Picture, User Image, Gravatar, Avatar Group — in this system all of them are Avatar.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The fallback chain
Image, initials, generic glyph. The broken-image icon is the one outcome that must never reach the screen, and it is the browser default.
Sizes
Below 20px initials stop being legible and the avatar becomes a coloured dot. That is the floor — anything smaller should be a Badge.
Stacked groups
Overlapped by about a third with a ring in the surface colour, so each face stays separable. Past four, count the rest.
Round for people, square for things
A durable convention: circles are people, rounded squares are organisations, repositories and projects. Mixing them makes a list unscannable.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Every part, every measurement, and the reason it is that number.
A circle carrying initials, a presence dot ringed in the surface colour, and a stack overlapped by a third with a counter for the rest.
- ShapeCircle, or 8px square
Circles are people; rounded squares are organisations and projects. The convention is strong enough that breaking it makes a mixed list unscannable.
- InitialsMax 2 characters
Two at every size. Three are illegible at 24px, and names do not reliably yield three meaningful parts anyway.
- Type scale~40% of the diameter
Scales with the avatar rather than stepping, so a 20px and a 56px avatar look like the same component.
- BackgroundDerived from the name
A hash into the visualisation palette. It aids scanning and is never the identity — two people will collide.
- Presence dot~28% of the diameter
Ringed in the surface colour so it separates from the avatar beneath, and positioned at the bottom-right where a face is least informative.
- Stack overlap−33%, with a 2px ring
A third is enough to read as a group while keeping each face separable. The ring is what stops two adjacent avatars merging.
- Stack orderFirst on top
The first avatar overlaps the second, so reading order matches visual order. Reversed z-index makes the last person appear most prominent.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --p-viz-1 … --p-viz-8 | — | Name-derived background, mixed to 22% alpha so it composes over any surface — categorical, never status |
| --ds-fg | — | The initials. The identity hue is a fill; as text it measured 3.9:1 |
| --ds-layer-active | — | The unknown-entity fallback |
| --ds-fg-muted | — | The generic person glyph |
| --ds-surface | — | The ring around a presence dot and around stacked avatars |
| --ds-success | — | Online presence |
| --ds-warning | — | Away presence |
| --ds-danger | — | Busy presence |
Radius
| Token | Value | Used for |
|---|---|---|
| full | — | People |
| --radius-md | Organisations and projects |
Typography
| Token | Value | Used for |
|---|---|---|
| weight | Initials, which are small and need the weight |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Type | When to use |
|---|---|---|---|
| xs | 20px | 9px | Inside a Chip or a dense table cell. The floor for legible initials. |
| sm | 24px | 10px | Table rows and inline mentions. |
| md | 32px | 11px | The default. List rows and comment authors. |
| lg | 40px | 13px | Card headers and hover cards. |
| xl | 56px | 18px | A profile header, where the avatar is the subject. |
| Presence dot | ~28% of diameter | — | With a 2px ring in the surface colour. Below 20px there is no room for it. |
src failed → initialsNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Initials must reach 4.5:1 against their derived background. A palette generated by hashing will produce failures unless the foreground is chosen per swatch.
- The presence dot needs its 2px ring to reach 3:1 against whatever is beneath — the avatar itself is an unpredictable background.
- Presence must not rely on colour alone: an accessible label carries the state, since green and amber are indistinguishable for many users at 8px.
- A stack ring owes 3:1 against the adjacent avatar, or the group merges into one shape.
Keyboard
| Tab | Nothing — an avatar is not interactive on its own. |
| Tab | Reaches it only when it is inside a link or a button, in which case that ancestor carries the name and the focus ring. |
Screen readers
- Avoid announcing the name twice. If the name is beside the avatar, the avatar should be silent.
- A stack announces once, as a group: "Ada, Grace, Alan and 3 others" beats six separate images.
- Presence needs a text equivalent. "Ada Lovelace, online" is one announcement; a green dot is none.
Focus & touch
- An avatar is never focusable on its own. When it opens a profile it is inside a link or a button, and that ancestor carries the accessible name and the focus ring — never the image.
- An avatar that opens something needs a 44px target, which usually means padding around a 32px avatar rather than a larger avatar. In a stack, only the counter should be interactive — overlapping targets are ambiguous to hit and impossible to describe.
| Attribute | Applied to | Notes |
|---|---|---|
| alt | The image | The person’s name, not "avatar" or "profile picture". If the name is already beside it, alt="" and let the text speak. |
| aria-hidden | A decorative avatar | When the name is right next to it, the avatar is duplication — hide it rather than announcing the name twice. |
| aria-label | The presence dot | "Online". A coloured dot with no label is meaningless to a screen reader and to many sighted users. |
| aria-label | A stack | "Ada, Grace, Alan and 3 others". Six unlabelled images is six announcements of nothing. |
| role="img" | An initials avatar | With aria-label naming the person. Two letters read literally are "A, L", which identifies nobody. |
Example usage
1import { Avatar, AvatarStack } from '@/ui/Display'23<Avatar name="Ada Lovelace" src={user.avatarUrl} size="md" status="online" />45<AvatarStack people={reviewers} max={4} size="sm" />67// The fallback chain IS the component. A broken-image icon must never reach8// the screen, and it is the browser's default for a failed src.9function Avatar({ name, src, size }: AvatarProps) {10 const [failed, setFailed] = React.useState(false)11 const showImage = src && !failed1213 return (14 <span role="img" aria-label={name} className={cls(size)}>15 {showImage ? (16 <img src={src} alt="" onError={() => setFailed(true)} />17 ) : (18 initials(name) || <UserIcon aria-hidden />19 )}20 </span>21 )22}2324// Names do not split reliably. "van der Berg", "李", a mononym — take the25// first grapheme of the first and last parts and accept one character.26function initials(name: string) {27 const parts = name.trim().split(/\s+/).filter(Boolean)28 if (parts.length === 0) return ''29 const first = [...parts[0]][0] ?? ''30 const last = parts.length > 1 ? ([...parts[parts.length - 1]][0] ?? '') : ''31 return (first + last).toUpperCase()32}3334// Colour aids scanning; it is never the identity. Two people will collide.35function hue(name: string) {36 let h = 037 for (const ch of name) h = (h * 31 + ch.codePointAt(0)!) % VIZ_COLORS.length38 return VIZ_COLORS[h]39}Framework-free HTML
<!-- With an image: the name is the alt text, never "avatar". -->
<span class="ds-avatar ds-avatar--md">
<img src="/u/ada.jpg" alt="Ada Lovelace" />
</span>
<!-- Initials: role="img" with a label, because "A L" identifies nobody. -->
<span class="ds-avatar ds-avatar--md" role="img" aria-label="Grace Hopper"
style="--avatar-bg: color-mix(in oklab, var(--p-viz-3) 22%, transparent)">
<span aria-hidden="true">GH</span>
</span>
<!-- Beside the name, the avatar is duplication: hide it. -->
<div class="ds-row">
<span class="ds-avatar" aria-hidden="true"><img src="/u/ada.jpg" alt="" /></span>
<span>Ada Lovelace</span>
</div>
<!-- Presence needs a text equivalent. -->
<span class="ds-avatar">
<img src="/u/ada.jpg" alt="Ada Lovelace" />
<span class="ds-avatar__status" data-status="online">
<span class="sr-only">Online</span>
</span>
</span>
<!-- One announcement for the group, not six. -->
<span class="ds-avatar-stack" role="img"
aria-label="Ada, Grace, Alan and 3 others">…</span>CSS
.ds-avatar {
position: relative;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
border-radius: 999px; /* circles are people */
/* An alpha tint, so the same avatar lifts off a card and a drawer as
readily as off the page. Mixing toward a surface token freezes it at one
rung and inverts everywhere else. */
background: var(--avatar-bg, var(--ds-layer-active));
/* The hue identifies the person from the FILL. Read as text it measures
3.9-4.4:1 across the eight, because the viz ramp is tuned for fills. */
color: var(--ds-fg);
font-weight: 600; /* initials are small and need it */
overflow: hidden;
user-select: none;
}
/* Rounded squares are organisations, repositories and projects. */
.ds-avatar--square { border-radius: var(--radius-md); }
/* Scales with the avatar rather than stepping, so 20px and 56px read as the
same component. */
.ds-avatar { font-size: calc(var(--avatar-size) * 0.4); }
.ds-avatar img {
inline-size: 100%;
block-size: 100%;
object-fit: cover; /* never squash a non-square photo */
}
/* The ring separates the dot from an unpredictable background. */
.ds-avatar__status {
position: absolute;
inset-block-end: 0;
inset-inline-end: 0;
inline-size: calc(var(--avatar-size) * 0.28);
block-size: calc(var(--avatar-size) * 0.28);
border-radius: 999px;
box-shadow: 0 0 0 2px var(--ds-surface);
}
.ds-avatar__status[data-status='online'] { background: var(--ds-success); }
.ds-avatar__status[data-status='away'] { background: var(--ds-warning); }
.ds-avatar__status[data-status='busy'] { background: var(--ds-danger); }
/* A third of overlap: enough to read as a group, enough to stay separable. */
.ds-avatar-stack { display: inline-flex; }
.ds-avatar-stack > * + * { margin-inline-start: -33%; }
.ds-avatar-stack > * {
box-shadow: 0 0 0 2px var(--ds-surface);
/* First on top, so reading order matches visual order. */
position: relative;
}Component API
Avatar
| Prop | Type | Default | Description |
|---|---|---|---|
| name* | string | — | Drives the initials, the derived colour and the accessible label. Required even when an image is present. |
| src | string | — | Falls back to initials on error. The onError handler is the main path for most users. |
| size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 20px to 56px. Below xs, initials stop being legible. |
| square | boolean | false | For organisations, repositories and projects. Circles are for people. |
| status | 'online' | 'away' | 'busy' | 'offline' | — | Adds a ringed presence dot with a visually hidden label. |
AvatarStack
| Prop | Type | Default | Description |
|---|---|---|---|
| people* | { name: string; src?: string }[] | — | In reading order. The first is rendered on top. |
| max | number | 4 | Past this, a "+n" counter. Five is the practical ceiling before faces stop being separable. |
| size | 'xs' | 'sm' | 'md' | 'sm' | Stacks are dense by nature; larger sizes overlap too much to read. |
Professional tips
- Serve avatars at twice their display size for high-density screens and no larger. A 512px image rendered at 32px is 250 KB thrown away.
- Cache the derived colour per user id rather than recomputing the hash on every render — it also keeps the colour stable if the display name changes.
- Show the full name on hover via a Tooltip in dense lists, and always have the name in text somewhere for touch users.
- For organisations, prefer a logo over initials where one exists, but keep the same square shape so the list stays scannable.
- Uploaded avatars should be cropped to a square on the client before upload. A component that squashes non-square photos is a component nobody trusts with their face.
Performance
- Lazy-load avatars below the fold, but never the ones in the viewport — a page of empty circles filling in is worse than a slightly later paint.
- Use a sprite or a single request for a stack rather than six separate image requests in a table with fifty rows.
- Render initials as text, not as generated SVG or canvas. Text is cached, scalable, selectable by the accessibility tree and free.
- Set explicit width and height so a late-loading image does not shift the row it sits in.
Common mistakes
- No onError handler, so a failed image shows the browser’s broken-image icon.
- Three or more initials, illegible at small sizes.
- alt="avatar" instead of the person’s name.
- The name announced twice — once by the avatar and once by the text beside it.
- Colour as the only identity signal, which collides and fails in greyscale.
- Avatars below 20px, where initials become an unreadable smudge.
- A presence dot with no accessible label.
- object-fit missing, squashing every non-square photo.
Real-world recommendations
- Most users never upload a photo. The initials path is the main path, not the fallback, and it deserves the majority of the design attention.
- Name-derived colours are genuinely useful for scanning a long activity feed — but only alongside the initials, never instead of them.
- Stacked avatars work best at three or four. Past that people stop counting and start reading it as "a lot", at which point the number is doing the work.
- Square avatars for organisations is one of the most reliable conventions in product UI. Users pick it up without being told, and breaking it is immediately disorienting.
