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.
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.
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.
circlePeopleroundedWorkspaces, repossquareLogos, botscircle 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.
softsoft, neutralsoft, primarysoft, secondarysoft, dangersolidsolid, neutralsolid, primarysolid, secondarysolid, dangeroutlineoutline, neutraloutline, primaryoutline, secondaryoutline, dangerBoth 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.
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.
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.
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.
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.
| Property | Controls | Default |
|---|---|---|
--forte-avatar-size | Both dimensions of the box. size re-points it; everything else here is a fraction of it. | 2.5rem |
--forte-avatar-radius | Corner radius. shape re-points it. | 50% |
--forte-avatar-font-size | Size 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-weight | Weight of the initials. | var(--forte-font-weight-medium) |
--forte-avatar-icon-size | Size of an svg inside the fallback. | calc(var(--forte-avatar-size) * 0.55) |
--forte-avatar-duration | How long the image takes to fade in once it has loaded. | var(--forte-duration-fast) |
--forte-avatar-ring-width | Width 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-color | Colour 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-solid | Tone's solid fill | var(--forte-gray-12) |
--forte-avatar-tone-on-solid | Text colour on the solid fill | var(--forte-gray-1) |
--forte-avatar-tone-soft | Tone's tinted fill | var(--forte-gray-3) |
--forte-avatar-tone-text | Text colour on the tinted fill, and for outline | var(--forte-color-foreground) |
--forte-avatar-tone-border | Edge colour for outline | var(--forte-color-border) |
--forte-avatar-bg | Fill behind the fallback. Set by variant from the tone. | var(--forte-avatar-tone-soft) |
--forte-avatar-fg | Colour of the initials or icon. Set by variant from the tone. | var(--forte-avatar-tone-text) |
--forte-avatar-border-width | Border width. 1px for variant="outline", 0px otherwise. | 0px |
--forte-avatar-border-color | Border 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.
| Property | Controls | Default |
|---|---|---|
--forte-avatar-group-overlap | How 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-width | Width of the ring the group draws around every avatar inside it. | 2px |
--forte-avatar-group-ring-color | Colour 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.
| Property | Controls | Default |
|---|---|---|
--forte-avatar-badge-size | Diameter 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-inset | How 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-width | Width 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-color | Colour 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-radius | Corner 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-size | Size of any text inside. | calc(var(--forte-avatar-size) * 0.18) |
--forte-avatar-badge-font-weight | Weight of that text. | var(--forte-font-weight-medium) |
--forte-avatar-badge-bg | Fill. Set by tone. | var(--forte-gray-12) |
--forte-avatar-badge-fg | Colour 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
shape | AvatarShape | circle | The 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. |
size | AvatarSize | md | Diameter 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. |
tone | AvatarTone | neutral | Which 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. |
variant | AvatarVariant | soft | How 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
| Prop | Type | Default | Description |
|---|---|---|---|
alt* | string | What 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. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Avatar.Fallback
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
delay | number | 0 | How 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. |
label | string | The 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
label | string | What 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. | |
placement | AvatarBadgePlacement | bottom-end | Which 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. |
tone | AvatarBadgeTone | neutral | Which 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |