Skip to main content

Avatar

A small image representing a person or an account, with a text or icon fallback for when there is no picture — or before one loads.

The shape carries the meaning. appearance="circle" is a person; appearance="square" (a rounded square) is one of the things people belong to: an account, workspace, team, or organization. A row that shows both, an account switcher with the signed-in user's photo beside it, stays readable without labels because the two never look alike.

Jane Doe
import { Avatar } from "@ngrok/mantle/avatar";import { UserIcon } from "@phosphor-icons/react/User"; export function AccountAndUserAvatars() {	return (		<div className="flex items-center gap-4">			{/* an account: rounded square, color derived from its id */}			<Avatar.Root appearance="square" colorSeed="acc_acme">				<Avatar.Fallback name="Acme Corp" />			</Avatar.Root> 			{/* a person with a photo: the fallback covers the load and any failure */}			<Avatar.Root>				<Avatar.Image alt="Jane Doe" src="/example-avatar.svg" />				<Avatar.Fallback name="Jane Doe" />			</Avatar.Root> 			{/* a person with no photo and no name to abbreviate: the avatar names itself */}			<Avatar.Root aria-label="Your account" className="text-muted" role="img">				<Avatar.Fallback>					<UserIcon className="size-4" />				</Avatar.Fallback>			</Avatar.Root>		</div>	);}

Composition

Avatar.Root├── Avatar.Image└── Avatar.Fallback

Avatar.Image and Avatar.Fallback are siblings, not alternatives you branch on. The fallback renders in flow; the image is positioned over it. A loading image shows initials through it, a loaded one covers them, and a failed one unmounts itself and reveals them again: no loading state for anyone to handle, and nothing waiting on hydration, because the <img> is in the server-rendered markup. (A transparent image is the one case where initials show through a loaded picture; give it its own backdrop if that matters.)

Polymorphism

Avatar.Root, Avatar.Image, and Avatar.Fallback each accept asChild. Reach for it on Image to swap in a framework image component, on Root when the avatar is itself a link or a button, and on Fallback when the initials want a more specific element (an <abbr title="…">, say).

import { Avatar } from "@ngrok/mantle/avatar"; export function LinkedAvatar() {	return (		<Avatar.Root asChild appearance="square" colorSeed="acc_acme">			<a href="/accounts/acme" title="Acme Corp">				<Avatar.Fallback name="Acme Corp" />			</a>		</Avatar.Root>	);}

Appearance

Circles are people, rounded squares are accounts. Nothing enforces it, but keeping the two distinct is what lets a dense row of both stay legible, so pick per subject rather than per screen.

Jane Doe
Acme Corp
import { Avatar } from "@ngrok/mantle/avatar"; export function Appearances() {	return (		<div className="flex items-center gap-6">			<div className="flex items-center gap-2">				<Avatar.Root appearance="circle" colorSeed="usr_jane">					<Avatar.Fallback name="Jane Doe" />				</Avatar.Root>				<span className="text-sm">Jane Doe</span>			</div>			<div className="flex items-center gap-2">				<Avatar.Root appearance="square" colorSeed="acc_acme">					<Avatar.Fallback name="Acme Corp" />				</Avatar.Root>				<span className="text-sm">Acme Corp</span>			</div>		</div>	);}

Deterministic color

colorSeed hashes a stable identifier into one of a fixed set of swatches, so an account is the same color on every device and every session with nothing stored and nothing fetched. Seed it with an id, not a display name: names get edited, and a rename would silently recolor the avatar.

Omit colorSeed for a neutral surface, or set your own bg-* through className to opt out entirely.

Acme Corp
Atlas Industries
Globex
Initech
Umbrella Group
import { Avatar } from "@ngrok/mantle/avatar"; const accounts = [	{ id: "acc_acme", name: "Acme Corp" },	{ id: "acc_atlas", name: "Atlas Industries" },	{ id: "acc_globex", name: "Globex" },	{ id: "acc_initech", name: "Initech" },	{ id: "acc_umbrella", name: "Umbrella Group" },]; export function SeededAvatars() {	return (		<div className="flex flex-wrap items-center gap-4">			{accounts.map((account) => (				<div key={account.id} className="flex items-center gap-2">					<Avatar.Root appearance="square" colorSeed={account.id}>						<Avatar.Fallback name={account.name} />					</Avatar.Root>					<span className="text-sm">{account.name}</span>				</div>			))}		</div>	);}

Sizing

An avatar is size-7 and shrink-0: a fixed-size visual that the flex rows it usually sits in (a switcher row, a table cell, a comment header) must not be able to squeeze. Override the size with className, which merges last and wins, and scale the text with it so initials stay proportional.

import { Avatar } from "@ngrok/mantle/avatar"; export function Sizes() {	return (		<div className="flex items-end gap-4">			<Avatar.Root className="size-5 text-[0.625rem]" colorSeed="usr_jane">				<Avatar.Fallback name="Jane Doe" />			</Avatar.Root>			<Avatar.Root colorSeed="usr_jane">				<Avatar.Fallback name="Jane Doe" />			</Avatar.Root>			<Avatar.Root className="size-10 text-sm" colorSeed="usr_jane">				<Avatar.Fallback name="Jane Doe" />			</Avatar.Root>			<Avatar.Root className="size-16 text-lg" colorSeed="usr_jane">				<Avatar.Fallback name="Jane Doe" />			</Avatar.Root>		</div>	);}

Accessibility

An avatar is almost always redundant with text beside it, and mantle defaults to that reading:

  • Initials derived from name are aria-hidden. They abbreviate a name the page already carries, so announcing them would read it twice ("A C Acme Corp"). Children are not hidden; you own their semantics.
  • When the avatar is the only thing naming its subject (a collapsed rail, a bare stack of members), name the root: role="img" plus aria-label="Acme Corp". A bare <span> is role="generic", where accessible names are prohibited, so the role is what makes the label count.
  • alt on Avatar.Image is required, and mantle makes it required deliberately: an <img> with no alt announces its URL. Pass "" when adjacent text already names the subject. What alt cannot do is name the avatar in every state: that <img> is absent whenever there is no src, the load fails, or the page is server-rendered, so an avatar that must always be named needs the role="img" + aria-label above as well.

Composing with MediaObject

For a name and a secondary line beside the avatar, compose MediaObject rather than hand-rolling the row.

Jane Doe

jane@acme.com

import { Avatar } from "@ngrok/mantle/avatar";import { MediaObject } from "@ngrok/mantle/media-object"; export function MemberRow() {	return (		<MediaObject.Root className="max-w-sm">			<MediaObject.Media>				<Avatar.Root className="size-9" colorSeed="usr_jane">					<Avatar.Fallback name="Jane Doe" />				</Avatar.Root>			</MediaObject.Media>			<MediaObject.Content>				<p className="text-strong text-sm font-medium">Jane Doe</p>				<p className="text-muted text-sm">jane@acme.com</p>			</MediaObject.Content>		</MediaObject.Root>	);}

API Reference

Avatar.Root

The sizing, shape, and surface. Renders a <span>, so it stays valid inside a switcher row's <button>. Accepts all <span> props (asChild, className, style, ref, data-*, aria-*, role), plus:

PropTypeDefaultDescription
appearance?"circle" | "square""circle"The shape. "circle" for a person, "square" (a rounded square) for an account, workspace, or team.
colorSeed?string—A stable id that deterministically selects a background swatch and switches the foreground to text-static-white. Omit for a neutral surface. Seed with an id, not a name.
data-slot?string—An ancestor-forwarded data-slot chain, joined ahead of this part's own avatar rather than replacing it.

Data attributes

Data AttributeValueDescription
data-slot"avatar"On the avatar surface, with any forwarded chain ahead of it. Styling/test hook.

Avatar.Image

The picture, layered over the fallback. A plain <img>, so the server renders it and a cached image needs no hydration to appear. A failed load unmounts it, revealing the fallback. Accepts all <img> props (srcSet, sizes, loading, referrerPolicy, crossOrigin, asChild, …), plus:

PropTypeDefaultDescription
altstring—Required, stricter than the <img> element — an image with no alt announces its URL. Pass "" when adjacent text already names the subject. It cannot name the avatar in the states where no <img> is mounted.
src?string—The picture's URL. Nullish or empty renders nothing and the fallback stands alone, so an optional URL passes straight through without branching.
onError?(event: SyntheticEvent) => void—Runs before the image unmounts itself; preventDefault() keeps it mounted, for a consumer who would rather retry than fall back. Changing src is always a fresh attempt.
data-slot?string—An ancestor-forwarded data-slot chain, joined ahead of this part's own avatar-image.

Data attributes

Data AttributeValueDescription
data-slot"avatar-image"On the <img>, with any forwarded chain ahead of it. Styling/test hook.

Avatar.Fallback

What shows when there is no image — while one loads, when it fails, or when there was never one. Renders a <span> that is always present, behind Avatar.Image. Accepts all <span> props (asChild, className, ref, aria-*), plus exactly one source of content:

PropTypeDefaultDescription
childrenReactNode—What to show: an icon, a monogram, initials you derived yourself. Announced normally. Mutually exclusive with name — the type forbids passing both.
namestring—A display name, rendered as at most two uppercase initials ("Acme Corp" → "AC", an unusable name → "?"). aria-hidden, since it abbreviates text already beside the avatar. Not for asChild.
data-slot?string—An ancestor-forwarded data-slot chain, joined ahead of this part's own avatar-fallback.

Data attributes

Data AttributeValueDescription
data-slot"avatar-fallback"On the fallback, with any forwarded chain ahead of it. Styling/test hook.