Skip to content

Avatar

An avatar is mostly a promise about the absence of a picture. The photo is the easy half; the half that decides whether a member list looks finished is what fills the circle while the image is in flight, when the URL 404s, and when there is no URL at all. Base UI's primitive owns that state machine — it preloads the image off-screen and only mounts the <img> once the load actually succeeds — and this component gives it a box, a fallback, and a way to stack several of them.

avatar/basic.tsx
AL
Ada LovelaceOwner
Bea Rivera

Import

import { Avatar } from "@forte-ui/react";

Avatar is a namespace of five parts. Avatar.Root, Avatar.Image and Avatar.Fallback are Base UI's anatomy; Avatar.Badge and Avatar.Group are added here, and neither has state of its own.

<Avatar.Root>
  <Avatar.Image src={user.photo} alt={user.name} />
  <Avatar.Fallback label={user.name}>{initials}</Avatar.Fallback>
  <Avatar.Badge tone="success" label="Online" />
</Avatar.Root>

Both children are optional and neither is ever rendered speculatively: exactly one of them is in the DOM at a time, chosen by the image's loading status. An avatar with no Avatar.Image is a perfectly ordinary avatar — the fallback simply never gets replaced.

Examples

Sizes

Five presets, xs through xl, spanning 1.5rem to 4rem. Only one property changes between them: the initials, the icon and the rounded corner are all fractions of --forte-avatar-size, so a size the presets do not cover is one custom property rather than four.

avatar/sizes.tsx
CBxs
CBsm
CBmd
CBlg
CBxl
Dara Okonjo--forte-avatar-size: 6rem

Shape

circle is the convention for people and the default. rounded and square read as an object instead — a workspace, a repository, a bot — which is a useful distinction to keep when both appear in the same list.

avatar/shapes.tsx
ENcirclePeople
ENroundedWorkspaces, repos
ENsquareLogos, bots

circle stays a circle under data-forte-radius="none". That preset squares off the library's corners, and a caller who wrote shape="circle" was describing the thing, not decorating it. rounded does follow the preset, because there roundness is the styling.

Variants and tones

The two axes the rest of the library uses. variant is how loud the fallback is — soft is a tinted disc, solid fills with the tone itself, outline is a ring around nothing — and tone is which semantic colour set it draws from.

avatar/variants.tsx
softsoft, neutralsoft, primarysoft, secondarysoft, danger
solidsolid, neutralsolid, primarysolid, secondarysolid, danger
outlineoutline, neutraloutline, primaryoutline, secondaryoutline, danger

Both only decide what shows while there is no image: a loaded photo covers the fill entirely. outline is the exception and the reason it exists — its edge sits outside the picture, so it is the one treatment that survives the photo arriving.

When the image is missing

Avatar.Fallback renders whenever the image has not loaded — which covers "still loading", "404", "blocked by the network" and "there was never a src" with the same code path.

avatar/fallback.tsx
Dara OkonjoLoads
Dara Okonjo404s
Dara OkonjoNo src

The middle avatar's src points at a file that does not exist. Nothing torn ever paints, because the <img> element is not mounted until a preload of the same URL has already succeeded.

delay on Avatar.Fallback holds the fallback back for a number of milliseconds. It defaults to 0, which is right whenever the fallback is the avatar: a non-zero delay also withholds it during server rendering, so an initials-only avatar would ship as an empty disc and pop in a tenth of a second later. Set it to around 120 when there is an Avatar.Image beside it — a cached image is not decoded until after hydration, so at 0 the initials paint and are immediately replaced on every load. The cost is an empty disc for the same span on a slow connection, which is the trade the prop exists to let you make.

Groups

Avatar.Group overlaps its children into a stack and draws a ring around each one so the overlap reads as separation rather than as a smear.

avatar/group.tsx
Ada LovelaceBea RiveraCyrus BellDara Okonjo3 more peopleAda LovelaceBea RiveraCyrus Bell

It is layout and nothing else — no state, no context, no props. Every avatar keeps its own size, shape, variant and tone; the group only pulls each child back by --forte-avatar-group-overlap and pushes a ring onto every descendant Avatar.Root. That ring is a real border on a pseudo-element rather than a box-shadow, so it is still there under forced colours, where shadows are stripped and a stack of avatars would otherwise fuse into one blob.

Children paint in DOM order, so the last one is on top. Put an overflow counter there — it is where the eye finishes, and it is the element most likely to be a link.

The overlap is a length, not a fraction of the avatar's size. The avatar's size is declared on the avatar's own root, and the margin has to be applied to whatever the group's direct child is — which may be a tooltip trigger or a list item wrapping the avatar rather than the avatar itself. A length works in every case; a fraction would silently collapse to nothing as soon as anyone wrapped a child.

Presence and badges

Avatar.Badge hangs a presence dot, a small count or a verification tick on the corner. It is position: absolute against the root, which is why the root deliberately does not clip its overflow — each part rounds itself off with border-radius: inherit instead, leaving the edge free.

avatar/presence.tsx
AL
Ada LovelaceOnline
BR
Bea RiveraAway
CB
Cyrus BellOffline

The part earns its place on the arithmetic, which is the half a hand-rolled <span> gets wrong. A dot at inset: 0 sits on the corner of the box, and the corner of the box is not on the avatar's outline unless the avatar is a square. The point where the 45° diagonal crosses a corner of radius r is 0.2929r in from the box corner — 14.645% for a circle, 0 for square, and whatever data-forte-radius currently makes the corner for rounded. One expression covers all three shapes, and it stays right when you override --forte-avatar-radius by hand.

Sizing is the other half, and the two are the same bug. --forte-avatar-badge-size is a fraction of --forte-avatar-size, so the offset that centres the dot on the outline moves with the avatar. A fixed length looks correct at exactly one size: at xl the dot floats off the disc, and at xs it is half the avatar.

avatar/badge.tsx
ALAda Lovelace, online, smALAda Lovelace, online, mdALAda Lovelace, online, lgALAda Lovelace, online, xl
BRBea Rivera, verified, circleBRBea Rivera, verified, roundedBRBea Rivera, verified, square
DO4 unread messagesDO12 unread messagesALAda Lovelace, onlineBRBea Rivera, awayCBCyrus Bell, offline

Content widens the badge into a pill, growing inward from the pinned edge — --forte-avatar-badge-size becomes its min-inline-size rather than its width, so 4 stays a disc and 12 does not become an ellipse. --forte-avatar-badge-ring-color follows --forte-avatar-ring-color by default, so a badge inside a group picks up the group's ring colour without being told and a lone avatar falls back to the page background.

placement matters most in a group. Avatars overlap toward the inline-start, so every avatar but the last has its inline-end corner painted over by the next one — put the dots on bottom-start there, or badge only the last avatar.

Accessibility

An avatar is a picture, and the accessibility question is always the same one: does this element have to name the person, or is their name already on screen next to it?

alt on Avatar.Image is required, because there is no defensible default. Pass the person's name when the avatar is the only thing identifying them, and "" when their name is written beside it — a photo announced as "Ada Lovelace" immediately before the text "Ada Lovelace" is worse than a photo announced as nothing.

A group of avatars needs the same treatment one level up. Each avatar names itself, so a stack reads as a list of people rather than as a single graphic — but nothing says what the stack is. If the grouping carries meaning, give the group an accessible name and, where the members are genuinely a list, render it as one with render.

Decorative icons inside a fallback take aria-hidden="true", and a bare Avatar.Badge is decoration too — it is hidden from the accessibility tree unless you pass label. That is the right default only because the status has to be available as text somewhere regardless, per SC 1.4.1: the presence demo writes "Online" beside the avatar and passes no label, because announcing it twice is worse than announcing it once.

A badge with visible content is never decoration, so it stays in the tree whether or not you name it — hiding a +3 on the absence of a prop would be a silent deletion. label then does what it does on Avatar.Fallback: it replaces what is announced, so 4 can read as "4 unread messages" instead of as the number four.

Under forced colours the badge needs more help than the fallback does, because both of its colours are backgrounds as far as that mode is concerned — the fill and the ring are each remapped to Canvas, and the dot disappears into the avatar rather than merely losing its tone. A bare dot is repainted as a CanvasText disc with a Canvas ring around it; a badge with content keeps a Canvas fill so its text stays legible. Every tone looks identical there, which is correct rather than a compromise — colour was never allowed to be the only cue.

Every text pair the component can produce clears AA. The tones draw the same slots the rest of the library does — -11 on -3 for soft, -11 on -1 for outline, on-primary on -9 for solid — and those are the pairs the contrast harness sweeps across ~119k seeds, so initials stay legible whatever the brand colour is.

Under forced colours the fallback gains a CanvasText border. Backgrounds are replaced with system colours in that mode, so every variant's fill would otherwise collapse to Canvas and the avatar would dissolve into the page — while an image avatar survives untouched, because bitmaps are left alone. The group's ring needs no such rule: it is already a border, and border-color is remapped rather than stripped.

Motion

One transition, on one property. The image fades in over --forte-duration-fast once it has loaded, and fades out again if a later src fails.

It is a transition rather than a keyframe animation for the usual reason: a src that changes twice in quick succession reverses from wherever the fade got to instead of snapping back and restarting. There is no scale and no travel, so there is nothing for reduced motion to collapse — and nothing that needs collapsing. The duration token shortens itself, and a fade is the one enter effect that stays legible when it does. There is no prefers-reduced-motion block in this stylesheet.

The root is a one-cell grid with the image and the fallback both placed in that cell. Base UI keeps an outgoing image mounted until its exit transition finishes, and for those frames the fallback is mounted too; in normal flow that would be two boxes side by side, doubling the avatar's width and snapping it back.

Theming

Everything below is declared on Avatar.Root itself, which is also why an ancestor is the wrong place to set one — the element's own declaration beats an inherited value. Override them on Avatar.Root through its className or a style object, or re-point the global tokens in the Default column to move every avatar at once.

Theming tokens for Avatar
PropertyControlsDefault
--forte-avatar-sizeBoth dimensions of the box. size re-points it; everything else here is a fraction of it.2.5rem
--forte-avatar-radiusCorner radius. shape re-points it.50%
--forte-avatar-font-sizeSize of the initials. Proportional so one --forte-avatar-size override is enough; re-point it at a --forte-font-size-* token to put the type scale back in charge.calc(var(--forte-avatar-size) * 0.4)
--forte-avatar-font-weightWeight of the initials.var(--forte-font-weight-medium)
--forte-avatar-icon-sizeSize of an svg inside the fallback.calc(var(--forte-avatar-size) * 0.55)
--forte-avatar-durationHow long the image takes to fade in once it has loaded.var(--forte-duration-fast)
--forte-avatar-ring-widthWidth of the ring drawn OUTSIDE the box. 0px, not 0 — it is negated in a calc() below, which needs a unit to keep the length valid.0px
--forte-avatar-ring-colorColour of that ring. It should match whatever the avatar sits on, so the gap it punches reads as background rather than as a stroke.var(--forte-color-background)
--forte-avatar-tone-solidTone's solid fillvar(--forte-gray-12)
--forte-avatar-tone-on-solidText colour on the solid fillvar(--forte-gray-1)
--forte-avatar-tone-softTone's tinted fillvar(--forte-gray-3)
--forte-avatar-tone-textText colour on the tinted fill, and for outlinevar(--forte-color-foreground)
--forte-avatar-tone-borderEdge colour for outlinevar(--forte-color-border)
--forte-avatar-bgFill behind the fallback. Set by variant from the tone.var(--forte-avatar-tone-soft)
--forte-avatar-fgColour of the initials or icon. Set by variant from the tone.var(--forte-avatar-tone-text)
--forte-avatar-border-widthBorder width. 1px for variant="outline", 0px otherwise.0px
--forte-avatar-border-colorBorder colour. The tone's border for variant="outline".transparent

Avatar.Group has three of its own. They are declared on the group and reach the avatars through a descendant selector rather than by inheritance, for the same reason: an avatar's own --forte-avatar-ring-width would beat anything the group merely passed down.

Theming tokens for Avatar
PropertyControlsDefault
--forte-avatar-group-overlapHow far each avatar slides under the one before it. A length, so it is independent of what the children are; tuned for size="md", so raise it for a group of larger avatars.var(--forte-space-3)
--forte-avatar-group-ring-widthWidth of the ring the group draws around every avatar inside it.2px
--forte-avatar-group-ring-colorColour of that ring. Change it to the surface the group sits on — the default assumes the page background.var(--forte-color-background)

Avatar.Badge declares its own, and they are set on the badge, not on the root: an element's own declaration beats an inherited one, so --forte-avatar-badge-size on Avatar.Root would be ignored.

Theming tokens for Avatar
PropertyControlsDefault
--forte-avatar-badge-sizeDiameter of the badge, and its minimum width once it has content. A fraction of the avatar, so size and any --forte-avatar-size override carry the badge with them.calc(var(--forte-avatar-size) * 0.3)
--forte-avatar-badge-insetHow far the badge's CENTRE sits in from the corner of the box. The default puts it on the avatar's own outline, whatever shape is; 0 hangs it on the box corner, and a larger value pulls it inside.calc(0.2929 * var(--forte-avatar-radius))
--forte-avatar-badge-ring-widthWidth of the ring that separates the badge from the picture under it. A border, not a shadow, for the reason in note 3.2px
--forte-avatar-badge-ring-colorColour of that ring. Follows the avatar's own ring by default, so a badge inside a group picks up the group's ring colour for free and a lone avatar falls back to the page background.var(--forte-avatar-ring-color)
--forte-avatar-badge-radiusCorner radius. Half the badge's own height, which is a disc while the badge is square and a true pill once content widens it — 50% would bend that pill into an ellipse. Derived rather than pointed at --forte-radius-pill for the reason shape="circle" avoids the radius scale too: a round presence dot is the meaning, not the styling, so it must survive data-forte-radius="none".calc(var(--forte-avatar-badge-size) / 2)
--forte-avatar-badge-font-sizeSize of any text inside.calc(var(--forte-avatar-size) * 0.18)
--forte-avatar-badge-font-weightWeight of that text.var(--forte-font-weight-medium)
--forte-avatar-badge-bgFill. Set by tone.var(--forte-gray-12)
--forte-avatar-badge-fgColour of the content. Set by tone, and paired with the fill so a count or a tick clears AA on every one of them.var(--forte-gray-1)

State is on data-size, data-shape, data-variant and data-tone, and on the badge it is data-tone, data-placement and data-dot — the last set only while the badge is empty, which is the flag the forced-colors rule needs and CSS cannot work out for itself. All of it is reachable from plain CSS or a Tailwind arbitrary variant — data-[shape=square]:... — without a wrapper element.

API reference

Avatar.Root, Avatar.Image and Avatar.Fallback forward every prop they do not consume to their Base UI counterpart, and take render for changing the underlying element. Avatar.Image also takes the whole <img> surface — src, srcSet, sizes, loading, crossOrigin — plus onLoadingStatusChange, which reports idle, loading, loaded or error if you need the state outside CSS.

Avatar.Badge and Avatar.Group have no Base UI counterpart to forward to. They are <span>s and take the whole span surface, ref included, but no render.

Avatar.Root

Props for AvatarRoot
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
shapeAvatarShapecircleThe silhouette. `circle` is the convention for people; `rounded` and `square` read as an object — a workspace, a repository, a bot. `circle` stays a circle under `data-forte-radius="none"`, because you asked for one. Only `rounded` follows the radius preset.
sizeAvatarSizemdDiameter of the avatar — `1.5rem` through `4rem`. Everything inside is a fraction of it, so the initials, the icon and the corner radius all follow without a second prop.
toneAvatarToneneutralWhich semantic colour set the fallback draws from. Rotating the tone across a list is the cheapest way to make initials tell people apart — but colour is never the only cue, since the initials themselves differ.
variantAvatarVariantsoftHow loud the fallback is. `soft` is a tinted disc with tone-coloured initials, `solid` fills with the tone itself, `outline` is a ring around nothing. This only decides what shows while there is no image — a loaded image covers the fill entirely. `outline` is the exception: its border sits outside the picture and stays visible either way.

Avatar.Image

Props for AvatarImage
PropTypeDefaultDescription
alt*stringWhat the picture shows. Required, because there is no sensible guess: the fallback's initials are gone the moment the image loads, so an avatar with no alt text and no adjacent name leaves the person unidentifiable. Pass the person's name when the avatar is the only thing naming them, and `""` when their name is already written beside it — a photo repeated as "Jane Doe Jane Doe" is worse than no alt at all.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Avatar.Fallback

Props for AvatarFallback
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
delaynumber0How long to wait before showing the fallback, in milliseconds. Left at Base UI's `0`, which is right whenever the fallback IS the avatar: a non-zero delay withholds it during server rendering too, so an initials-only avatar would ship as an empty disc and pop in a tenth of a second later. Set it to ~100–150ms when there is an `Avatar.Image` beside it. A cached image is not decoded until after hydration, so at `0` the initials paint and are replaced on every load; a short wait swallows that flash, at the cost of an empty disc for the same span on a slow connection.
labelstringThe person or thing this avatar stands for, for assistive technology only. Initials are a visual shorthand and read as noise — "J D" — so passing a label hides the children from the accessibility tree and announces this instead. Leave it unset when the name is already written next to the avatar, or when the fallback is a decorative icon.

Avatar.Badge

Props for AvatarBadge
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
labelstringWhat the badge means, for assistive technology. A bare dot is decoration and is hidden from the accessibility tree unless you pass this. A badge with visible content stays in the tree either way — `label` then replaces what is announced, the way it does on `Avatar.Fallback`, so `4` can read as "4 unread messages". Leave it unset when the status is already written next to the avatar.
placementAvatarBadgePlacementbottom-endWhich corner it sits on. `bottom-end` is the convention, and it is also the corner a group covers: avatars in an `Avatar.Group` overlap toward the inline-start, so every avatar but the last has its inline-end corner painted over by the next one. Use `bottom-start` there, or badge only the last avatar.
toneAvatarBadgeToneneutralWhich semantic colour set the badge draws from. `success`, `warning` and `danger` are the presence set — online, away, do-not-disturb — and the brand tones suit a count or a verification tick. Colour is never the only cue: pass `label`, or write the status out beside the avatar, so the badge is repeating something rather than saying it.

Avatar.Group

Props for AvatarGroup
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.