Avatar
A person's photo or initials in a circle, alone or in a group.
Approvers
Friday freight run needs all three before it sends.
- Maya OkaforVP of RevenueApproved Sep 25
- Priya RamanSupport leadApproved Sep 26
- Jordan LeeAccounts payableWaiting
import { Avatar, AvatarFallback, AvatarGroup } from "@oration/canon/components/avatar";import { Button } from "@oration/canon/components/button";import { identityColors } from "@oration/canon/components/identicon";import { toast } from "@oration/canon/components/toast";export function Hero() { const approvers = [ { name: "Maya Okafor", initials: "MO", role: "VP of Revenue", state: "Approved Sep 25", }, { name: "Priya Raman", initials: "PR", role: "Support lead", state: "Approved Sep 26", }, { name: "Jordan Lee", initials: "JL", role: "Accounts payable", state: "Waiting", }, ]; return ( <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-start justify-between gap-3"> <div className="flex min-w-0 flex-col gap-0.5"> <p className="text-sm font-semibold text-foreground"> Approvers </p> <p className="text-13 text-muted-foreground"> Friday freight run needs all three before it sends. </p> </div> <span className="sr-only"> Approvers:{" "} {approvers.map((person) => person.name).join(", ")} </span> <AvatarGroup aria-hidden="true" className="-space-x-1.5 *:data-[slot=avatar]:ring-card" > {approvers.map((person) => { const tint = identityColors(person.name); return ( <Avatar key={person.name} size="sm"> <AvatarFallback className="text-[11px] font-semibold" style={{ backgroundColor: tint.bg, color: tint.fg, }} > {person.initials} </AvatarFallback> </Avatar> ); })} </AvatarGroup> </div> <ul className="flex flex-col"> {approvers.map((person) => { const tint = identityColors(person.name); return ( <li key={person.name} className="flex items-center gap-3 border-t border-border py-2.5" > <Avatar aria-hidden="true"> <AvatarFallback className="font-semibold" style={{ backgroundColor: tint.bg, color: tint.fg, }} > {person.initials} </AvatarFallback> </Avatar> <span className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-13 font-medium text-foreground"> {person.name} </span> <span className="truncate text-xs text-muted-foreground"> {person.role} </span> </span> <span className="text-xs text-muted-foreground tabular-nums"> {person.state} </span> </li> ); })} </ul> <div className="flex justify-end"> <Button type="button" variant="outline" size="sm" onClick={() => toast.add({ title: "Reminder sent to Jordan Lee", description: "Friday freight run is waiting on one approval.", }) } > Remind Jordan </Button> </div> </div> );}Usage#
Avatar puts a person in a circle: their photo when there is one, their initials when there isn't. It marks who owns, approved, attended or was mentioned on something, beside their name in rows, headers and comment threads, and stacks into a group when several people share a record. Circles are for people only. Agents get an Agent avatar and companies get a Monogram tile. The common mistake is an avatar standing in for a name: without the name beside it, or an accessible name on the group, nobody can tell Priya from Wen.
When to use
- Beside a person's name in a row, cell, comment or record header: the owner of a supplier, the approver of a payment run.
- For a signed-in person's own identity in the user menu and profile settings, with their photo if they uploaded one.
- As a stack of overlapping circles for the people on a meeting, a team or an approval chain, with a
+ncount past four. - With
AvatarBadgefor a person's presence, when a text label for the same state sits nearby.
When not to use
- For an AI agent. Agents have a Bayer mark in a rounded tile, and a live ring on a call. Use Agent avatar
- For a company, supplier or provider. Organizations get a square letter tile, never a circle. Use Monogram tile
- For a record without a person or photo, such as a cohort or an anonymous caller. Use Identicon
- As the only way to say who something belongs to in a dense table. Write the name; add the avatar if there is room. Use Table
The Option Hue Rule
The Label-Beside-Color Rule
Anatomy#
- Root. A round
<span>, 32px by default (24px atsm, 40px atlg). An::afterhairline in--border, blended withmix-blend-darken(lighten in dark), rims photos that would otherwise bleed into the page. - Image.
AvatarImage, a square<img>cropped to the circle withobject-cover. Base UI mounts it only once the file has loaded. - Fallback.
AvatarFallback, shown until the image loads or when there is none: initials on Well Gray in Slate Meta, 14px (12px atsm). Tint it with the person's identity hue. - Badge. Optional
AvatarBadgeat the bottom right, 8, 10 or 12px by avatar size, with a 2px ring in the plane color so it reads as cut out.
Examples#
Sizes
24, 32 and 40px. Use sm in dense rows and comment threads, the default beside a 13px name, and lg in record headers.
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";export function Sizes() { const sizes = [ { size: "sm", label: "sm, 24px" }, { size: "default", label: "default, 32px" }, { size: "lg", label: "lg, 40px" }, ] as const; return ( <div className="flex items-end gap-8"> {sizes.map((item) => ( <div key={item.size} className="flex flex-col items-center gap-2" > <Avatar size={item.size} aria-hidden="true"> <AvatarFallback className="font-semibold"> TF </AvatarFallback> </Avatar> <span className="text-xs text-muted-foreground"> {item.label} </span> </div> ))} </div> );}Photo and initials
The fallback shows until the photo loads and whenever there is none. delay holds the initials back for 300ms so a fast photo doesn't flash them first.
import { Avatar, AvatarFallback, AvatarImage } from "@oration/canon/components/avatar";import { Button } from "@oration/canon/components/button";import * as React from "react";export function ImageAndFallback() { const [hasPhoto, setHasPhoto] = React.useState(true); const photo = `data:image/svg+xml,${encodeURIComponent( '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#d9d2c8"/><circle cx="32" cy="26" r="12" fill="#9c7a62"/><path d="M8 64c2-15 12-22 24-22s22 7 24 22z" fill="#55606e"/></svg>', )}`; return ( <div className="flex w-full max-w-sm items-center gap-4 rounded-xl bg-card p-4 shadow-border"> <Avatar className="size-12" aria-hidden="true"> {hasPhoto ? <AvatarImage src={photo} alt="" /> : null} <AvatarFallback delay={hasPhoto ? 300 : 0} className="font-semibold" > MO </AvatarFallback> </Avatar> <div className="flex min-w-0 flex-1 flex-col"> <span className="text-13 font-medium text-foreground"> Maya Okafor </span> <span className="text-xs text-muted-foreground"> {hasPhoto ? "Photo uploaded Sep 12" : "Initials until you add a photo"} </span> </div> <Button type="button" variant="outline" size="sm" onClick={() => setHasPhoto((value) => !value)} > {hasPhoto ? "Remove photo" : "Add photo"} </Button> </div> );}Identity tint
identityColors(name) returns a person's pale tag fill and deep ink, stable across sessions and themes, so the same person is the same color everywhere.
- Maya Okafor
- Priya Raman
- Tomás Ferreira
- Jordan Lee
- Aisha Bello
- Wen Zhou
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { identityColors } from "@oration/canon/components/identicon";export function IdentityTint() { const people = [ { name: "Maya Okafor", initials: "MO" }, { name: "Priya Raman", initials: "PR" }, { name: "Tomás Ferreira", initials: "TF" }, { name: "Jordan Lee", initials: "JL" }, { name: "Aisha Bello", initials: "AB" }, { name: "Wen Zhou", initials: "WZ" }, ]; return ( <ul className="grid w-full max-w-lg grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3"> {people.map((person) => { const tint = identityColors(person.name); return ( <li key={person.name} className="flex items-center gap-2.5"> <Avatar aria-hidden="true"> <AvatarFallback className="font-semibold" style={{ backgroundColor: tint.bg, color: tint.fg, }} > {person.initials} </AvatarFallback> </Avatar> <span className="truncate text-13 text-foreground"> {person.name} </span> </li> ); })} </ul> );}Group
Up to four avatars and a +n count. The group is hidden from screen readers and the names are written out in an sr-only span beside it.
import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@oration/canon/components/avatar";import { identityColors } from "@oration/canon/components/identicon";import { cn } from "@oration/canon/lib/utils";export function Group() { const attendees = [ "Maya Okafor", "Priya Raman", "Tomás Ferreira", "Jordan Lee", "Aisha Bello", "Wen Zhou", ]; const shown = attendees.slice(0, 4); const extra = attendees.length - shown.length; return ( <div className="flex flex-col items-center gap-6"> {(["sm", "default"] as const).map((size) => ( <div key={size} className="flex items-center gap-3"> <span className="sr-only"> Attendees: {shown.join(", ")} and {extra} others </span> <AvatarGroup aria-hidden="true" className={size === "sm" ? "-space-x-1.5" : undefined} > {shown.map((name) => { const tint = identityColors(name); return ( <Avatar key={name} size={size}> <AvatarFallback className={cn( "font-semibold", size === "sm" ? "text-[11px]" : "text-xs", )} style={{ backgroundColor: tint.bg, color: tint.fg, }} > {name .split(" ") .map((part) => part[0]) .join("")} </AvatarFallback> </Avatar> ); })} <AvatarGroupCount className={ size === "sm" ? "text-[11px]" : "text-xs" } > +{extra} </AvatarGroupCount> </AvatarGroup> <span className="text-13 text-muted-foreground"> Northwind Freight renewal call </span> </div> ))} </div> );}Presence badge
AvatarBadge recolored with a status tone for presence, always with the state written next to the name.
- Priya RamanAvailable
- Tomás FerreiraIn a meeting
- Wen ZhouOffline
import { Avatar, AvatarBadge, AvatarFallback } from "@oration/canon/components/avatar";import { identityColors } from "@oration/canon/components/identicon";export function Presence() { const team = [ { name: "Priya Raman", initials: "PR", presence: "Available", dot: "bg-success", }, { name: "Tomás Ferreira", initials: "TF", presence: "In a meeting", dot: "bg-warning", }, { name: "Wen Zhou", initials: "WZ", presence: "Offline", dot: "bg-subtle-foreground", }, ]; return ( <ul className="flex w-full max-w-xs flex-col gap-3"> {team.map((person) => { const tint = identityColors(person.name); return ( <li key={person.name} className="flex items-center gap-2.5"> <Avatar aria-hidden="true"> <AvatarFallback className="font-semibold" style={{ backgroundColor: tint.bg, color: tint.fg, }} > {person.initials} </AvatarFallback> <AvatarBadge className={person.dot} /> </Avatar> <span className="flex min-w-0 flex-col"> <span className="text-13 font-medium text-foreground"> {person.name} </span> <span className="text-xs text-muted-foreground"> {person.presence} </span> </span> </li> ); })} </ul> );}States#
| State | Treatment |
|---|---|
| No image | The fallback renders at once with the person's initials. |
| Loading | The fallback shows while the photo loads. Pass delay on the fallback (for example 300ms) to skip a flash of initials on a fast connection. |
| Loaded | Base UI swaps in the image and unmounts the fallback. data-[slot=avatar] and the root's loading status are available for styling. |
| Error | If the photo fails, the image never mounts and the fallback stays. Nothing breaks and no broken-image glyph appears. |
| With badge | AvatarBadge sits in the corner. At sm it's a plain 8px dot and hides any icon; at default and lg it can hold an 8px icon. |
| Grouped | Inside AvatarGroup, avatars overlap by 8px and each gets a 2px ring in the plane color so the edges stay legible. |
Behavior#
- Base UI tracks the image's loading status on the root (
idle,loading,loaded,error) and renders the image or the fallback, never both.onLoadingStatusChangeonAvatarImagereports it. - Swapping
srcrestarts loading; the fallback returns until the new photo is ready. - Avatars are not interactive. To open a profile, wrap the avatar and the name together in one link or button so there is one target with a real name.
AvatarGroupis a flex row with-space-x-2. Tighten the overlap with-space-x-1.5at 24px. Later avatars sit on top of earlier ones.AvatarGroupCountmatches the group's size throughgroup-has-data-[size=…], so a count next tolgavatars is 40px without extra props.- The size prop sets
data-sizeon the root, which the fallback and badge read to scale their text and dot.
Do and don't#
- Priya Raman
- Wen Zhou
AvatarGroupCount.Content#
- Initials are two letters, first and last name: MO for Maya Okafor, TF for Tomás Ferreira. One letter only when there is one name.
- Photos use
alt=""when the name is written beside them, and the person's name when it isn't. - Presence labels are short states in sentence case: Available, In a meeting, Away, Offline.
- Counts read as
+3, and the accessible text names everyone: Attendees: Maya Okafor, Priya Raman, Jordan Lee and 3 others.
Accessibility#
- The root has no role. When the name is visible beside it, hide the avatar with
aria-hidden="true"so initials aren't read as a word (MO). - When the avatar stands alone, give the image an
altwith the person's name, or put ansr-onlyname next to the fallback. - Hide an
AvatarGroupwitharia-hiddenand put the full list of names in ansr-onlyspan beside it, as the meetings list does. - A presence badge is decorative color. Pair it with visible text, or an
sr-onlystate such as , available. - Tinted initials use the same fill and ink pairs as tags, which are drawn for 12px text. Don't set initials smaller than 11px.
Design tokens#
| Token | Used for |
|---|---|
--muted | Fallback and group count fill |
--muted-foreground | Fallback initials and count text |
--border | The ::after hairline rim, blended darken (lighten in dark) |
--background | The 2px ring around grouped avatars, counts and badges |
--primary | Default AvatarBadge fill |
--tag-{hue}, --tag-{hue}-fg | Identity tint on the fallback, from identityColors(seed) |
rounded-full | Every avatar, image, fallback and badge |
API reference#
Avatar
The round root. Built on Base UI Avatar.Root.
Other props spread onto Base UI Avatar.Root (<span>).
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "default" | "lg" | "default" | 24, 32 or 40px. Written to data-size. |
render | ReactElement | (props, state) => ReactElement | No default | Render the root as another element. |
className | string | No default | Merged last. Use it for one-off sizes such as size-12 in profile settings. |
AvatarImage
The photo, mounted once it has loaded.
Other props spread onto Base UI Avatar.Image (<img>).
| Prop | Type | Default | Description |
|---|---|---|---|
srcRequired | string | No default | The photo URL. |
altRequired | string | No default | The person's name, or "" when the name is visible beside it. |
onLoadingStatusChange | (status: "idle" | "loading" | "loaded" | "error") => void | No default | Called as the image loads or fails. |
keepMounted | boolean | false | Keeps the <img> mounted and loads in place, for loading="lazy" or next/image. |
AvatarFallback
Initials or an icon, shown when there is no loaded image.
Other props spread onto Base UI Avatar.Fallback (<span>).
| Prop | Type | Default | Description |
|---|---|---|---|
delay | number | 0 | Milliseconds to wait before showing the fallback, to avoid a flash while a fast photo loads. |
children | React.ReactNode | No default | Two initials, or a 16px icon. |
className | string | No default | Merged last, so tag tint classes replace Well Gray and Slate Meta. |
AvatarBadge
A dot in the bottom-right corner, sized by the avatar's data-size.
Other props spread onto <span>.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | No default | Optional icon, drawn at 8px at default and lg, hidden at sm. |
className | string | No default | Override the fill for presence, such as bg-success for Available. |
AvatarGroup
A row of overlapping avatars with a 2px plane-colored ring on each.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | Tighten the overlap with -space-x-1.5; on cards use *:data-[slot=avatar]:ring-card. |
AvatarGroupCount
The +n circle at the end of a group. Sizes itself from the group's avatars.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | No default | The overflow count, such as +3, or a 16px icon. |
className | string | No default | Merged last. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The product rarely renders Avatar directly. PersonAvatar in components/records/avatars.tsx wraps it with its own 16, 20, 24, 32 and 48px ramp, a 24px default and tag tints from the record's color. The contact center has a second PersonAvatar in components/contact-center/ui.tsx that skips Avatar entirely and draws its own presence dot. Three avatar recipes coexist.
Call sites override the root's rim to after:border-black/5 dark:after:border-white/10 because the default --border rim reads heavy on tinted fallbacks.
AvatarGroup and AvatarGroupCount ring in --background. On a card in dark mode that is the wrong color, so call sites add ring-card by hand.
AvatarBadge defaults to a Quiet Indigo fill, which the Quiet Indigo Rule reserves for the viewer's own markers. It isn't used anywhere in the product; presence is drawn by hand.
The product sets 24px fallbacks at 10px and 20px ones at 9px, under the 11px floor in The Thirteen-Fourteen Rule.
The fallback has no weight; every call site adds font-semibold.
AvatarGroupCount is exported but missing from the registry's export list.
In a comment thread
Small avatars lead each comment at the top of the first line, with the name and time beside them. Reply to add Maya's comment.
Priya Raman9:12 AM
Northwind Freight says the Sep 18 remittance never arrived. Their AP inbox bounced our email.
Jordan Lee9:40 AM
Resent to the new address on their W-9. Trace number is in the payment record.