Skip to content

Avatar

A person's photo or initials in a circle, alone or in a group.

Status
Stable
Level
Atom
Category
Identity
Adoption
Not used yet
import { Avatar } from "@oration/canon/components/avatar";
packages/canon/src/components/avatar.tsx

Approvers

Friday freight run needs all three before it sends.

Approvers: Maya Okafor, Priya Raman, Jordan Lee
  • 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 +n count past four.
  • With AvatarBadge for 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 ten categorical hues belong to select-option values and to identity tints on avatars and monogram tiles. An initials fallback may take a person's identity tint; it never takes a status color or Quiet Indigo.

The Label-Beside-Color Rule

A presence badge is a colored dot, so it always travels with words: Available, In a meeting, Away. The dot alone doesn't say which.

Anatomy#

  1. Root. A round <span>, 32px by default (24px at sm, 40px at lg). An ::after hairline in --border, blended with mix-blend-darken (lighten in dark), rims photos that would otherwise bleed into the page.
  2. Image. AvatarImage, a square <img> cropped to the circle with object-cover. Base UI mounts it only once the file has loaded.
  3. Fallback. AvatarFallback, shown until the image loads or when there is none: initials on Well Gray in Slate Meta, 14px (12px at sm). Tint it with the person's identity hue.
  4. Badge. Optional AvatarBadge at 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.

sm, 24px
default, 32px
lg, 40px
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.

Maya OkaforPhoto uploaded Sep 12
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.

Attendees: Maya Okafor, Priya Raman, Tomás Ferreira, Jordan Lee and 2 othersNorthwind Freight renewal call
Attendees: Maya Okafor, Priya Raman, Tomás Ferreira, Jordan Lee and 2 othersNorthwind Freight renewal call
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>    );}

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.

import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Button } from "@oration/canon/components/button";import { identityColors } from "@oration/canon/components/identicon";import * as React from "react";export function CommentThread() {    const [comments, setComments] = React.useState([        {            id: "c1",            name: "Priya Raman",            initials: "PR",            time: "9:12 AM",            text: "Northwind Freight says the Sep 18 remittance never arrived. Their AP inbox bounced our email.",        },        {            id: "c2",            name: "Jordan Lee",            initials: "JL",            time: "9:40 AM",            text: "Resent to the new address on their W-9. Trace number is in the payment record.",        },    ]);    return (        <div className="flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-4 shadow-border">            <ul className="flex flex-col gap-4">                {comments.map((comment) => {                    const tint = identityColors(comment.name);                    return (                        <li key={comment.id} className="flex gap-3">                            <Avatar                                size="sm"                                aria-hidden="true"                                className="mt-0.5"                            >                                <AvatarFallback                                    className="font-semibold"                                    style={{                                        backgroundColor: tint.bg,                                        color: tint.fg,                                    }}                                >                                    {comment.initials}                                </AvatarFallback>                            </Avatar>                            <div className="flex min-w-0 flex-col gap-0.5">                                <p className="flex items-baseline gap-2">                                    <span className="text-13 font-medium text-foreground">                                        {comment.name}                                    </span>                                    <span className="text-xs text-muted-foreground tabular-nums">                                        {comment.time}                                    </span>                                </p>                                <p className="text-13 text-pretty text-foreground/90">                                    {comment.text}                                </p>                            </div>                        </li>                    );                })}            </ul>            <div className="flex justify-end border-t border-border pt-3">                <Button                    type="button"                    variant="outline"                    size="sm"                    disabled={comments.length > 2}                    onClick={() =>                        setComments((list) => [                            ...list,                            {                                id: "c3",                                name: "Maya Okafor",                                initials: "MO",                                time: "10:05 AM",                                text: "Thanks both. Marking the dispute resolved.",                            },                        ])                    }                >                    Reply as Maya                </Button>            </div>        </div>    );}

States#

States
StateTreatment
No imageThe fallback renders at once with the person's initials.
LoadingThe fallback shows while the photo loads. Pass delay on the fallback (for example 300ms) to skip a flash of initials on a fast connection.
LoadedBase UI swaps in the image and unmounts the fallback. data-[slot=avatar] and the root's loading status are available for styling.
ErrorIf the photo fails, the image never mounts and the fallback stays. Nothing breaks and no broken-image glyph appears.
With badgeAvatarBadge 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.
GroupedInside 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. onLoadingStatusChange on AvatarImage reports it.
  • Swapping src restarts 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.
  • AvatarGroup is a flex row with -space-x-2. Tighten the overlap with -space-x-1.5 at 24px. Later avatars sit on top of earlier ones.
  • AvatarGroupCount matches the group's size through group-has-data-[size=…], so a count next to lg avatars is 40px without extra props.
  • The size prop sets data-size on the root, which the fallback and badge read to scale their text and dot.

Do and don't#

  • Priya Raman
  • Wen Zhou
Do. Keep the name beside the avatar, or give the group an accessible list of names.
PRPRWZ
Don't. Show a row of initials with no names. Two people with the same initials become the same person.
MOPRTFJL
+5
Do. Stop the stack at four and count the rest with AvatarGroupCount.
MOPRTFJLABWZRKDSEN
Don't. Stack every attendee. The circles shrink to slivers and the row stops scanning.
Aisha Bello
Do. Tint an initials fallback with the person's identity hue, pale fill and deep ink.
Aisha Bello
Don't. Fill initials with Quiet Indigo or a status color. Indigo means selected or live, and red means something failed.

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 alt with the person's name, or put an sr-only name next to the fallback.
  • Hide an AvatarGroup with aria-hidden and put the full list of names in an sr-only span beside it, as the meetings list does.
  • A presence badge is decorative color. Pair it with visible text, or an sr-only state 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#

Design tokens
TokenUsed for
--mutedFallback and group count fill
--muted-foregroundFallback initials and count text
--borderThe ::after hairline rim, blended darken (lighten in dark)
--backgroundThe 2px ring around grouped avatars, counts and badges
--primaryDefault AvatarBadge fill
--tag-{hue}, --tag-{hue}-fgIdentity tint on the fallback, from identityColors(seed)
rounded-fullEvery 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>).

Props of Avatar
PropTypeDefaultDescription
size"sm" | "default" | "lg""default"24, 32 or 40px. Written to data-size.
renderReactElement | (props, state) => ReactElementNo defaultRender the root as another element.
classNamestringNo defaultMerged 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>).

Props of AvatarImage
PropTypeDefaultDescription
srcRequiredstringNo defaultThe photo URL.
altRequiredstringNo defaultThe person's name, or "" when the name is visible beside it.
onLoadingStatusChange(status: "idle" | "loading" | "loaded" | "error") => voidNo defaultCalled as the image loads or fails.
keepMountedbooleanfalseKeeps 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>).

Props of AvatarFallback
PropTypeDefaultDescription
delaynumber0Milliseconds to wait before showing the fallback, to avoid a flash while a fast photo loads.
childrenReact.ReactNodeNo defaultTwo initials, or a 16px icon.
classNamestringNo defaultMerged 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>.

Props of AvatarBadge
PropTypeDefaultDescription
childrenReact.ReactNodeNo defaultOptional icon, drawn at 8px at default and lg, hidden at sm.
classNamestringNo defaultOverride 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>.

Props of AvatarGroup
PropTypeDefaultDescription
classNamestringNo defaultTighten 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>.

Props of AvatarGroupCount
PropTypeDefaultDescription
childrenReact.ReactNodeNo defaultThe overflow count, such as +3, or a 16px icon.
classNamestringNo defaultMerged 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.