Avatar
pnpm dlx shadcn@latest add …/r/avatar.jsonAn accessible React avatar for Next.js and Tailwind CSS: a picture or initials in three sizes, alone or in an overlapping group, built on Radix Avatar.
- OMJLSmall
- OMJLMedium
- OMJLLarge
- OMJLINWK+3Group
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export function UserAvatar({ user }) {
return (
<Avatar>
<AvatarImage src={user.image} alt={user.name} />
<AvatarFallback>{user.initials}</AvatarFallback>
</Avatar>
)
}import {
Avatar,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
} from "@/components/ui/avatar";
/*
* Pictures drawn as small grey SVGs, so the demo works offline. Use your people's photo URLs instead: when one is
* missing or can't load, the initials stay, named by the alt.
*/
const picture = (shapes: string) =>
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" fill="white"/>${shapes}</svg>`,
)}`;
const people = [
{
name: "Olivia Martin",
initials: "OM",
picture: picture(
'<rect width="40" height="40" fill-opacity=".7"/><circle cx="26" cy="14" r="6" fill="white" fill-opacity=".75"/><path d="M0 30q11-8 21-3t19-2v15H0z" fill-opacity=".45"/>',
),
},
{ name: "Jackson Lee", initials: "JL" },
{
name: "Isabella Nguyen",
initials: "IN",
picture: picture(
'<rect width="40" height="40" fill-opacity=".35"/><path d="M0 34 13 16l8 10 6-7 13 15v6H0z" fill-opacity=".55"/><path d="M0 40v-4l13-9 9 6 6-4 12 7v4z" fill-opacity=".35"/>',
),
},
{ name: "William Kim", initials: "WK" },
];
const sizes = [
{ size: "sm", label: "Small" },
{ size: "md", label: "Medium" },
{ size: "lg", label: "Large" },
] as const;
/* Each size with a picture and with initials, then a group with a count, each named underneath */
export default function AvatarSizes() {
return (
<ul className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6">
{sizes.map(({ size, label }) => (
<li key={size} className="flex flex-col items-center gap-2">
<div className="flex items-center gap-3">
{people.slice(0, 2).map((person) => (
<Avatar key={person.name} size={size}>
<AvatarImage src={person.picture} alt={person.name} />
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
))}
</div>
<span className="text-xs text-muted-foreground">{label}</span>
</li>
))}
<li className="flex flex-col items-center gap-2">
<AvatarGroup aria-label="Team, 7 people">
{people.map((person) => (
<Avatar key={person.name}>
<AvatarImage src={person.picture} alt={person.name} />
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
))}
<AvatarGroupCount>+3</AvatarGroupCount>
</AvatarGroup>
<span className="text-xs text-muted-foreground">Group</span>
</li>
</ul>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { blur, duration, ease } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { animate, useReducedMotion } from "motion/react"; import { Avatar as AvatarPrimitive } from "radix-ui"; import { type ComponentProps, createContext, type Ref, type RefObject, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; /* * An avatar on Radix Avatar (owner's roadmap, 2026-10-16: "A picture or initials, alone or in a group"), with * shadcn's parts: Avatar, AvatarImage, AvatarFallback, AvatarGroup and AvatarGroupCount ("+3"), in three sizes. * - Motion (.agents/rules/motion.md): once the picture has loaded, it comes in through the blur over the initials, * which stay underneath until it's sharp, so nothing pops or jumps. With reduced motion it only fades. * - Nothing animates on first paint: the picture comes in only when the avatar was on screen without it first * (rendered on the server, or still loading). A picture that's ready on the avatar's first paint just shows. * - The initials speak for the picture: while they stand in, they carry its alt as their name, so a screen reader * hears "Olivia Martin" whether or not the picture has loaded. With alt="" (a name beside it) they're hidden too. * - Radix mounts the img only once it has loaded, and drops its own fallback that moment. So the fallback here is * our own span, which the picture tells when it's fully in. * - In a group, a ring in the page's background colour separates the overlapping circles. The overlap follows the * size, and each covered avatar's initials centre in its visible part, so two letters are never cut. */ /** Where the picture is: not on screen, coming in over the initials, or fully in */ type Picture = "none" | "entering" | "shown"; const AvatarContext = createContext<{ picture: Picture; setPicture: (picture: Picture) => void; /** The picture's alt, which the initials carry while they stand in for it */ alt: string | undefined; setAlt: (alt: string | undefined) => void; /** Rendered on the server, so the initials were painted before the picture could load */ fromServer: boolean; } | null>(null); function useAvatar(part: string) { const avatar = useContext(AvatarContext); if (!avatar) throw new Error(`${part} must be used inside Avatar`); return avatar; } const subscribe = () => () => {}; const avatarVariants = cva("group/avatar relative flex shrink-0 overflow-hidden rounded-full select-none", { variants: { size: { sm: "size-6", md: "size-8", lg: "size-10", }, }, defaultVariants: { size: "md" }, }); export type AvatarProps = ComponentProps<typeof AvatarPrimitive.Root> & VariantProps<typeof avatarVariants>; function Avatar({ className, size, ...props }: AvatarProps) { // While hydrating, React reads the server value (false): the avatar knows its initials were painted first const hydrated = useSyncExternalStore( subscribe, () => true, () => false, ); const [fromServer] = useState(!hydrated); const [picture, setPicture] = useState<Picture>("none"); const [alt, setAlt] = useState<string>(); const value = useMemo(() => ({ picture, setPicture, alt, setAlt, fromServer }), [picture, alt, fromServer]); return ( <AvatarContext.Provider value={value}> <AvatarPrimitive.Root data-slot="avatar" data-size={size ?? "md"} className={cn(avatarVariants({ size }), className)} {...props} /> </AvatarContext.Provider> ); } /** Hands a node to our ref and to the caller's */ function useMergedRef<T>(own: RefObject<T | null>, theirs?: Ref<T>) { return useCallback( (node: T | null) => { own.current = node; if (typeof theirs === "function") theirs(node); else if (theirs) theirs.current = node; }, [own, theirs], ); } export type AvatarImageProps = ComponentProps<typeof AvatarPrimitive.Image>; /** * The picture. Radix loads it first and only then puts it on the page, so a broken or missing `src` leaves the * initials. Give it an `alt` with the person's name, or alt="" when the name is written beside the avatar. */ function AvatarImage({ className, alt, ref, onLoadingStatusChange, ...props }: AvatarImageProps) { const { setPicture, setAlt, fromServer } = useAvatar("AvatarImage"); const reduce = useReducedMotion() ?? false; const node = useRef<HTMLImageElement | null>(null); const setRef = useMergedRef(node, ref); const [loaded, setLoaded] = useState(false); // Whether the avatar has been on screen without its picture: then the picture comes in, rather than just // being there const waited = useRef(fromServer); // Read when the picture arrives, so a change of preference never replays it const reduceRef = useRef(reduce); useEffect(() => { reduceRef.current = reduce; }, [reduce]); useLayoutEffect(() => { setAlt(alt); return () => setAlt(undefined); }, [alt, setAlt]); // Radix reports "loaded" in the same commit that puts the img on the page, so this runs before it's painted useLayoutEffect(() => { const image = node.current; if (!loaded || !image) return; if (!waited.current) { setPicture("shown"); return () => setPicture("none"); } setPicture("entering"); image.style.opacity = "0"; let done = false; const controls = animate( image, reduceRef.current ? { opacity: [0, 1] } : { opacity: [0, 1], filter: [blur.hidden, blur.shown] }, { duration: duration.base, ease: ease.out }, ); controls.then(() => { if (done) return; done = true; // No filter or inline opacity left behind (ADR 0011) image.style.removeProperty("filter"); image.style.removeProperty("opacity"); setPicture("shown"); }); return () => { done = true; controls.stop(); setPicture("none"); }; }, [loaded, setPicture]); return ( <AvatarPrimitive.Image data-slot="avatar-image" ref={setRef} alt={alt} onLoadingStatusChange={(status) => { // Loading and failing both leave the initials on screen for at least a frame if (status === "loading" || status === "error") waited.current = true; setLoaded(status === "loaded"); onLoadingStatusChange?.(status); }} className={cn("absolute inset-0 size-full object-cover", className)} {...props} /> ); } export type AvatarFallbackProps = ComponentProps<"span"> & { /** Wait this long before showing the initials, so a picture that loads quickly never flashes them */ delayMs?: number; }; /** Initials (or an icon) shown until the picture has loaded, and instead of it when it can't */ function AvatarFallback({ className, delayMs, ...props }: AvatarFallbackProps) { const { picture, alt } = useAvatar("AvatarFallback"); const [ready, setReady] = useState(delayMs === undefined); useEffect(() => { if (delayMs === undefined) return; const timer = window.setTimeout(() => setReady(true), delayMs); return () => window.clearTimeout(timer); }, [delayMs]); if (!ready || picture === "shown") return null; // Standing in, it reads as the picture would. While the picture comes in over it, it's only seen. const standingIn = picture === "none"; const name = standingIn && alt ? { role: "img", "aria-label": alt } : {}; return ( <span data-slot="avatar-fallback" aria-hidden={!standingIn || alt === "" ? true : undefined} {...name} className={cn( "flex size-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground", "group-data-[size=sm]/avatar:text-xs group-data-[size=lg]/avatar:text-base", // In a group the next circle covers this one's end edge (its overlap plus its ring), so the initials centre // in the part that stays visible and are never cut "in-data-[slot=avatar-group]:group-not-last/avatar:pe-2", "group-data-[size=sm]/avatar:in-data-[slot=avatar-group]:group-not-last/avatar:pe-1", "group-data-[size=lg]/avatar:in-data-[slot=avatar-group]:group-not-last/avatar:pe-2.5", className, )} {...props} /> ); } /** * Avatars that overlap, each ringed in the page's background colour. A group (role="group"): name it with * aria-label, for example "Team". */ function AvatarGroup({ className, ...props }: ComponentProps<"div">) { return ( // biome-ignore lint/a11y/useSemanticElements: a group of pictures, not form fields, so not a fieldset <div role="group" data-slot="avatar-group" className={cn( // The overlap follows the size (2, 6 or 8px), small enough that two initials still fit beside it "group/avatar-group flex -space-x-1.5 has-data-[size=sm]:-space-x-0.5 has-data-[size=lg]:-space-x-2", "*:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background", className, )} {...props} /> ); } /** The last circle in a group, for the people not shown ("+3"). It takes the size of the avatars beside it. */ function AvatarGroupCount({ className, ...props }: ComponentProps<"span">) { return ( <span data-slot="avatar-group-count" className={cn( "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground ring-2 ring-background", "group-has-data-[size=sm]/avatar-group:size-6 group-has-data-[size=sm]/avatar-group:text-xs", "group-has-data-[size=lg]/avatar-group:size-10", className, )} {...props} /> ); } export { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };Update the import paths to match your project.
Examples
With a name
With the name written beside it, give the picture alt="": the avatar is hidden from screen readers and the name is read once.
- SDSofia Davissofia@example.com
- JLJackson Leejackson@example.com
- WKWilliam Kimwill@example.com
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
type Member = { name: string; email: string; initials: string; picture?: string };
// No pictures yet, so the initials stand in
const members: Member[] = [
{ name: "Sofia Davis", email: "sofia@example.com", initials: "SD" },
{ name: "Jackson Lee", email: "jackson@example.com", initials: "JL" },
{ name: "William Kim", email: "will@example.com", initials: "WK" },
];
/*
* With the name written beside it, the avatar is decoration: alt="" hides the picture and the initials from
* screen readers, so the name is read once.
*/
export default function AvatarWithName() {
return (
<ul className="grid w-full max-w-xs gap-4">
{members.map((member) => (
<li key={member.email} className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src={member.picture} alt="" />
<AvatarFallback>{member.initials}</AvatarFallback>
</Avatar>
<div className="grid min-w-0">
<span className="truncate text-sm font-medium">{member.name}</span>
<span className="truncate text-sm text-muted-foreground">{member.email}</span>
</div>
</li>
))}
</ul>
);
}API reference
Avatar
The circle. Also accepts every Radix Avatar Root prop.
| Prop | Type | Default | Description |
|---|---|---|---|
size? | "sm" | "md" | "lg" | "md" | 24, 32 or 40px. A group's count follows the avatars beside it. |
AvatarImage
The picture. It appears once it has loaded, coming in through the blur when the initials were on screen first; a missing or broken src leaves the initials. Also accepts every native img prop.
| Prop | Type | Default | Description |
|---|---|---|---|
src? | string | – | The picture's URL. |
alt? | string | – | The person's name. The initials carry it while they stand in. Use alt="" when the name is written beside the avatar. |
onLoadingStatusChange? | (status: "idle" | "loading" | "loaded" | "error") => void | – | Called as the picture loads, or fails to. |
AvatarFallback
Initials or an icon, shown until the picture is fully in, and instead of it when it can't load. Also accepts every native span prop.
| Prop | Type | Default | Description |
|---|---|---|---|
delayMs? | number | – | Wait this long before showing the initials, so a quick picture never flashes them. |
AvatarGroup
Overlapping avatars, each ringed in the page's background colour. The overlap follows their size, and initials centre in the part left visible. A group (role="group"): name it with aria-label.
| Prop | Type | Default | Description |
|---|
AvatarGroupCount
The last circle in a group, for the people not shown ("+3"), at the size of its neighbours.
| Prop | Type | Default | Description |
|---|
Related components
- Stablev2.4.0Beta
An accessible React badge for Next.js and Tailwind CSS: small status labels in five variants, including one with a light travelling round its edge.
- Team plan8 of 10 seats usedActive
A React card for Next.js and Tailwind CSS: a surface for grouped content with header, action, content and footer parts, a small size and a link style.
An accessible React scroll area for Next.js and Tailwind CSS: native scrolling with a slim scrollbar and soft fades at the edges, built on Radix.