⚠️ Design system CSS not found. Check the path in cms/docs.config.js → designSystemPath, then re-run npm run docgen.

Avatar

A person or organisation as a small circle

Website / Avatar
Download .md file
Open .md in new tab
On this page
  • Anatomy
  • Basic usage
  • Sizes
  • Shapes
  • With an image
  • Icon fallback
  • When the image fails
  • Groups
  • Composition
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product

An avatar reduces a person or an organisation to a small circle: a photograph where there is one, initials where there is not. It appears in account menus, comment threads, team lists and assignment pickers.

It carries no identity of its own. The name sits beside it in the markup, and the avatar is the visual shorthand — which is why it is hidden from assistive technology by default rather than announced as a stray letter.


Anatomy

div.avatar-group           optional; overlaps a set of avatars
  span.avatar              data-size="small" | "large" | "xlarge"
                           data-shape="circle" | "square" | "rounded"
                           data-color="accent" | "info"
    "E"                    an initial, or
    div.svg-icn            the user icon, or nothing at all
    img                    a photograph, layered over whichever is beneath

The three fills are a stack, not alternatives. Initials sit in the box as text; a photograph covers them from the layer above. That is what makes the error fallback work — remove the image and what was always underneath shows through.


Basic usage

E BD AM
<span class="avatar" aria-hidden="true">E</span>
<span class="avatar" data-color="accent" aria-hidden="true">BD</span>

One or two characters. Three fit, but the circle stops reading as a circle.

data-color is decoration, not meaning. It gives a set of initials some variety so a list of five people is not five identical grey circles — it does not encode role, status or availability. There is no colour here that means "online", and adding one would be a different component.

The set stops at two, and that is a contrast constraint, not an oversight. Badge and tag put saturated text on a light tint; this component does the opposite and paints --text-inverted straight onto the fill, which is a far tighter budget. A value earns its place only if it clears 4.5:1 in both themes and with no brand theme loaded — success reached 3.16:1 unthemed and was dropped for it. Do not add warning or danger without measuring; scripts/verify-avatar.js measures all three cases.

Note that accent and info resolve to the same blue in the light theme and diverge in the dark one, where accent is yellow. Two avatars side by side may match on one theme and not the other, so do not use the pair to draw a distinction a reader is meant to notice.


Sizes

E E E E
<span class="avatar" data-size="small" aria-hidden="true">E</span>
<span class="avatar" aria-hidden="true">E</span>
<span class="avatar" data-size="large" aria-hidden="true">E</span>
<span class="avatar" data-size="xlarge" aria-hidden="true">E</span>

The type scales from the box (calc(var(--avatar-size) * 0.4)), so one token resizes both and an initial can never outgrow its circle. A one-off size is a token re-point, not a new variant:

.comment-avatar {
  --avatar-size: 2.5rem;
}

Shapes

E E E
<span class="avatar" aria-hidden="true">E</span>
<span class="avatar" data-shape="rounded" aria-hidden="true">E</span>
<span class="avatar" data-shape="square" aria-hidden="true">E</span>

Circle is the default and needs no attribute; data-shape="circle" exists so a context that re-pointed --avatar-radius can put one avatar back.

Pick a shape per surface, not per person. A team list of circles with one square in it reads as an error, not a distinction — if the square is meant to say "this is an organisation, not a human", say that in the text as well.


With an image

<span class="avatar" data-size="large">
  <img src="/team/erlen.jpg" alt="" loading="lazy">
</span>

alt="" is deliberate. The image is decorative because the name is already in the text next to it — see Accessibility.

loading="lazy" on every avatar image below the fold. A team page renders dozens of them and each one is a request; the attribute costs nothing and is the reason the list does not stall on faces nobody has scrolled to. Leave it off for the one avatar in the header, which is visible immediately and would only be delayed by it.


Icon fallback

Where there is no photograph and no name to reduce to initials — an unassigned task, a pending invitation — the avatar falls back to the user icon.

<span class="avatar" aria-hidden="true">
  <div class="svg-icn" data-icon="user"><!-- inline svg --></div>
</span>

The icon scales with the box through --avatar-icon-scale and takes its colour from --avatar-color, so it works on every data-color without a second rule.

Use it when there is genuinely nobody to name. If you have a name, use initials — an icon where a person exists is a step backwards, not a neutral default.

If the icon is the message, say the message. The default aria-hidden assumes a name sits beside the avatar, and here there is no name — "unassigned" is a state carried entirely by a graphic. Either the word is already in the row, in which case keep the avatar hidden, or it is not, in which case name it:

<span class="avatar" role="img" aria-label="Unassigned">
  <div class="svg-icn" data-icon="user"><!-- inline svg --></div>
</span>

When the image fails

A remote photograph can 404, and the CSS cannot know. The initials are already in the box underneath, so the whole fix is to remove the image that failed:

<span class="avatar" aria-hidden="true">
  EM
  <img src="/team/erlen.jpg" alt="" loading="lazy">
</span>
function handleAvatarImageError(event) {
  const image = event.target;
  if (image instanceof HTMLImageElement && image.closest(".avatar")) {
    image.remove();
  }
}

document.addEventListener("error", handleAvatarImageError, true);

The listener is on document in the capture phase because error does not bubble — a listener attached the ordinary way never hears it. One delegated listener covers every avatar on the page, including ones added later.

This is deliberately not shipped as a design system module. It is four lines, it belongs to whatever renders the avatars, and a component that silently rewrote its own DOM would be harder to reason about than one that does not.

If a broken image is left in place it renders as an empty box over the initials — which browser you are in decides whether that is invisible or a grey rectangle. Do not rely on alt="" collapsing it.


Groups

Overlap a set of avatars to say "these people, together" — a project team, the participants in a thread.

EM BD AM +3
<div class="avatar-group">
  <span class="avatar" aria-hidden="true">EM</span>
  <span class="avatar" data-color="accent" aria-hidden="true">BD</span>
  <span class="avatar" data-color="info" aria-hidden="true">AM</span>
  <span class="avatar" aria-hidden="true">+3</span>
</div>

The overflow count is an avatar, not a new part. +3 is one or two characters in a circle, which is exactly what the component already does. It gets no separate class.

Size the group by re-pointing --avatar-size on the group rather than putting data-size on every child — the overlap scales from the same token, so the proportion holds:

.thread-participants {
  --avatar-size: 1.5rem;
}

Set the ring to whatever the group actually sits on. It defaults to --background-primary, which is right on the page and wrong inside a card:

.card .avatar-group {
  --avatar-group-ring-color: var(--background-secondary);
}

Each avatar overlaps the one before it, in DOM order. Put the people who matter most last if the order is yours to choose.

Keep a group to about five before the count takes over. Past that the faces are unreadable and you are showing a texture, not a team.


Composition

In an account menu

The avatar replaces the leading icon on an identity row.

<div class="dropdown" data-placement="bottom-end">
  <button class="dropdown-trigger" type="button" aria-haspopup="true" aria-expanded="false">
    <span class="avatar" data-size="small" aria-hidden="true">E</span>
    Account
  </button>
  <div class="dropdown-menu" role="menu">
    <button class="dropdown-item" role="menuitem" type="button">
      <span class="avatar" data-size="small" aria-hidden="true">E</span>
      Profile
    </button>
  </div>
</div>

See Site Header for the full account menu in a header.


Accessibility

Requirement How
The initial aria-hidden="true" on the avatar; a lone "E" announced mid-sentence is noise
The image alt="", for the same reason — it is decorative, not informative
The name Must be in the surrounding markup as real text
Contrast Measured per theme, not assumed — see below

Initials render at calc(--avatar-size * 0.4), which is 12.8px at the default size. That is normal text under WCAG, not large text, so the bar is 4.5:1 at every size — including xlarge, since one avatar in a set does not get a different rule.

Measured: accent 6.3:1 and info 12.8:1 in light, 9.9:1 and 12.9:1 in dark, 5.2:1 and 9.9:1 with no brand theme loaded. All pass. The info figures rose when the status text tokens lifted to the -dark primitives.

Those figures belong to the tokens, not to the component. data-color resolves through --background-accent and --status-info, which a brand instance re-points. Re-point one and the ratio moves with it; --avatar-color stays --text-inverted and will not compensate. Any instance that changes a status colour has to re-measure — scripts/verify-avatar.js checks the light theme, the dark theme, and design-system.css on its own, which is the case that ships in dist/.

If the avatar is the only thing identifying the person, none of the above applies — that is a different situation and the identity has to be announced. An avatar with no accessible name anywhere near it is a bug, not a style.

Labelling a standalone avatar

Name the box, not what is inside it:

<span class="avatar" role="img" aria-label="Erlen Masson">
  EM
  <img src="/team/erlen.jpg" alt="" loading="lazy">
</span>

role="img" is what stops "EM" being read as two letters of running text. Without it the aria-label has nothing to attach to — ARIA prohibits a name on the generic role a bare <span> carries, so browsers are within spec to drop it entirely.

Put the name on the box rather than in alt on the image. A real alt looks like the obvious answer and breaks twice over: the error handler above removes the image, taking the accessible name with it, and any surrounding aria-hidden suppresses it anyway. A name on the <span> survives both.

Never label it "Avatar of Erlen Masson" or "Erlen Masson's profile picture". The role already says image; the rest is the name.

aria-labelledby is for when the name is on screen but not adjacent in reading order — a card title above a stack of faces, a row header off to the side. When the name sits directly beside the avatar, the right answer is not a third labelling technique, it is the aria-hidden default: labelling it there just announces the name twice.

Groups

A static group needs one name, not five:

<div class="avatar-group" role="img" aria-label="Erlen Masson, Anna Muir and 3 others">
  <span class="avatar" aria-hidden="true">EM</span>
  <span class="avatar" aria-hidden="true">AM</span>
  <span class="avatar" aria-hidden="true">+3</span>
</div>

role="img" prunes its own descendants, so the aria-hidden on each child is belt-and-braces rather than required. Leave it — Safari has historically leaked the inner text of role="img" containers, and it costs nothing.

role="img" cannot contain focusable content. The pruning does not apply to focusable descendants, so the moment each avatar links to a person the group is a list of links, not an image:

<ul class="avatar-group" role="list">
  <li><a href="/team/erlen"><span class="avatar" aria-hidden="true">EM</span>Erlen Masson</a></li>
  <li><a href="/team/anna"><span class="avatar" aria-hidden="true">AM</span>Anna Muir</a></li>
</ul>

The name is the link text. Hide it visually if the design calls for faces alone — do not delete it.

The +3 must exist as text somewhere. It is the only statement that the group is truncated, and as an aria-hidden avatar it says nothing at all. Put the count in the group's aria-label, as above, or in visible text beside it. A screen reader user who is told about four people when there are seven has been given wrong information, not less of it.

Avatars are not interactive. If one opens a menu, the control is the button around it, and that button carries the label.


Usage rules

Do:

  • Keep the full name in the markup beside the avatar
  • Use one or two characters for initials
  • Re-point --avatar-size for a one-off size rather than adding a variant
  • Use alt="" on decorative images, and loading="lazy" below the fold
  • Keep initials in the box behind a photograph, so a failed image has something to fall back to
  • Re-point --avatar-group-ring-color to whatever the group actually sits on
  • Give a standalone avatar role="img" and a name

Don't:

  • Don't rely on the avatar alone to identify someone
  • Don't put three or more characters in it
  • Don't make the <span> itself clickable; wrap it in a <button> or <a>
  • Don't set a background colour directly — use data-color, or re-point --avatar-background
  • Don't set border-radius directly — use data-shape, or re-point --avatar-radius
  • Don't mix shapes within one set
  • Don't add a class for the overflow count; +3 is an avatar
  • Don't ship a +3 avatar as the only statement of the count — it is aria-hidden, so it says nothing
  • Don't wrap a group of links in role="img"; that role cannot contain focusable content
  • Don't put the name in alt on the image — the error fallback removes the image
  • Don't put a border on a grouped avatar — it draws inside, eating the photograph; the ring is a box-shadow
  • Don't leave a broken image in the DOM and expect alt="" to hide it
  • Don't use it for anything that is not a person or an organisation; a shape in a circle is an icon

CSS reference

This section documents how the component is built. For usage, see the sections above.

Tokens

Token Default What it controls
2rem Both axes, and the type size through it
Circle by default; data-shape re-points it
Circle fill
Initial and icon colour
0.55 Icon size as a fraction of the box
0.3 Overlap as a fraction of the avatar size, not a length
2px Ring separating overlapping avatars
Ring colour; match the surface behind the group

Selectors

Selector Purpose
Base component
.avatar img Photograph; covers the box from the layer above
.avatar > .svg-icn Icon fallback; scaled from the box, optical margin reset
.avatar[data-size="small"] 1.5rem
.avatar[data-size="large"] 3rem
.avatar[data-size="xlarge"] 4.5rem
.avatar[data-shape="circle"] ; the default, present so it can be restored
.avatar[data-shape="rounded"]
.avatar[data-shape="square"] 0
.avatar[data-color="accent"] Accent fill with inverted text
Flex row of overlapping avatars
.avatar-group .avatar Carries the ring; descendant, so a wrapped avatar keeps it
.avatar-group > *:not(:first-child) Negative inline-start margin, on the child whatever it is
@media (forced-colors: active) .avatar Border replacing the dropped ring

Key rules

Square by construction: inline-size and block-size both read --avatar-size, so there is no way to make it an ellipse by accident.
Type is calc(var(--avatar-size) * 0.4) rather than a step on the type scale — the initial has to stay inside the circle at every size, which a fixed scale cannot guarantee.
overflow: hidden is what clips a photograph to the radius; without it a square image sits over a round background.
The image is position: absolute; inset: 0 so it covers the initials instead of displacing them — the layered fallback depends on it.
data-shape re-points --avatar-radius rather than setting border-radius, so it composes with a context that already re-pointed the token.
--avatar-group-overlap is a unitless fraction multiplied by --avatar-size at the avatar, not a length. Declaring it as calc(var(--avatar-size) * 0.3) in :root looks equivalent and is not: a custom property containing var() is resolved where it is declared, so it would freeze at the :root size and a re-pointed group would keep the 2rem overlap. This was verified in a browser, not inferred.
The ring is a box-shadow, not a border. Not for layout reasons — under box-sizing: border-box a border draws inside the declared size and the footprint is unchanged — but because drawing inside is exactly the problem: the border would eat a ring of the photograph, and overflow: hidden would clip it against the radius. box-shadow paints outside the box and touches neither.
The group rules match as a descendant and hang the overlap on .avatar-group > *. A real team list wraps each avatar in the <a> that links to the person, which the component requires; a child combinator on would silently stop applying the moment anyone does that.
Under forced-colors, box-shadow is not painted and every background flattens, so a @media (forced-colors: active) block gives each avatar a border instead. Without it an overlapping group renders as stacked letters with no boundaries.


Use in another product

The design system installs once per product:

npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0

This component's styles ship in design-system.css. No JavaScript, nothing else to include.

On this page
  • Anatomy
  • Basic usage
  • Sizes
  • Shapes
  • With an image
  • Icon fallback
  • When the image fails
  • Groups
  • Composition
  • Accessibility
  • Usage rules
  • CSS reference
  • Use in another product
Previous App Navigation
Next Page Header

Was this page helpful?

We use this feedback to improve our documentation.

Thanks for your feedback

Send feedback

© 2026 By Default