FoundationsAvatar
An image with a designed fallback, on the Radix avatar primitive.
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."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 };