FoundationsAvatar

An image with a designed fallback, on the Radix avatar primitive.

Preview
APMV
AP

Abraham Philip

principal design engineer

The thinking

Almost every avatar in the wild is an <img> with a broken-image glyph waiting behind it. I build this one inside-out: the fallback is the real component and the photograph is the upgrade that may or may not arrive. Images fail constantly — slow networks, deleted accounts, blocked trackers — so the state people see most often is the one I styled most carefully. Radix owns the decision of which child shows, which is why there's no JS state to track and no window where both render at once. The circle is never empty, and it never looks like something failed — because nothing did.

Interface design

One size, one shape: h-10 w-10, rounded-full, overflow-hidden so the photograph clips to the circle rather than the circle stretching to the photograph. shrink-0 matters more than it looks — avatars live in flex rows next to names, and a squashed oval is the first thing flexbox does to an image when the text runs long. The fallback is a first-class state, not an error look: initials in text-sm font-medium on bg-secondary, set in text-secondary-foreground — the same ink as any other label. The image is pinned to aspect-square h-full w-full, so it fills the circle exactly.

Interaction

An avatar is a picture, not a control — no hover, no press, no intent to infer, because pointing at a photograph asks it for nothing. The one event it answers is its own image arriving: Radix mounts the image only after it finishes loading, which means a mount animation is the load fade — 200ms, ease-swift, opacity only. Without it, photos pop in unannounced over the initials, the one place an avatar can startle. motion-reduce:animate-none swaps instantly for anyone who asked things to hold still.

Sound design

Silent, and not by exception. The global SoundLayer speaks through semantics — one capturing click listener that fires only for elements carrying an interactive role, button, a[href], the ARIA widget roles — and an avatar carries no role at all, so the listener never matches it. It doesn't even need data-silent; that flag exists for components with an audible role that own a paired sound, and this one has neither. A picture that made a sound when tapped would be promising an interaction it can't deliver.

Empathy

Most of this component is the empathy case: the fallback exists for the moment a network or a deleted account lets the image down, and it's styled as a design, not an apology. Reduced motion is honored — motion-reduce:animate-none means the photo simply appears. There's no keyboard story because there's no tab stop; an avatar isn't interactive, and faking focusability would plant a dead stop in someone's tab order. For screen readers the whole surface is the alt you pass: the component adds no ARIA of its own, so a meaningful avatar gets a real name and a purely decorative one next to that same name takes alt="". The demo does both.
avatar.tsx
"use client";

import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";

import { cn } from "@/lib/utils";

const Avatar = ({
	ref,
	className,
	...props
}: React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & {
	ref?: React.Ref<React.ComponentRef<typeof AvatarPrimitive.Root>>;
}) => (
	<AvatarPrimitive.Root
		ref={ref}
		className={cn(
			"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
			className
		)}
		{...props}
	/>
);
Avatar.displayName = AvatarPrimitive.Root.displayName;

// radix mounts the image only after it has loaded, so a mount animation
// IS the load fade — 200ms, opacity only, no JS state to track
const AvatarImage = ({
	ref,
	className,
	...props
}: React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
	ref?: React.Ref<React.ComponentRef<typeof AvatarPrimitive.Image>>;
}) => (
	<AvatarPrimitive.Image
		ref={ref}
		className={cn(
			"aspect-square h-full w-full animate-overlay-in motion-reduce:animate-none",
			className
		)}
		{...props}
	/>
);
AvatarImage.displayName = AvatarPrimitive.Image.displayName;

// the fallback is a designed state, not an error look: initials on the
// secondary surface, same ink as any other text
const AvatarFallback = ({
	ref,
	className,
	...props
}: React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> & {
	ref?: React.Ref<React.ComponentRef<typeof AvatarPrimitive.Fallback>>;
}) => (
	<AvatarPrimitive.Fallback
		ref={ref}
		className={cn(
			"flex h-full w-full items-center justify-center rounded-full bg-secondary text-sm font-medium text-secondary-foreground",
			className
		)}
		{...props}
	/>
);
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;

export { Avatar, AvatarImage, AvatarFallback };