Avatar
A visual representation of a user that displays initials when an image is unavailable

PB
import { Avatar } from '@/components/avatar';
export default function AvatarPreview() {
return (
<Avatar>
<Avatar.Image src="https://github.com/pdrbrnd.png" />
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
);
} Source Code
import { UserIcon } from '@phosphor-icons/react/dist/ssr';
import type { VariantProps } from 'cva';
import { Children, cloneElement, isValidElement, useId } from 'react';
import { cn, cva } from '@/lib/utils/classnames';
const getInitials = (name: string | undefined) => {
if (!name) return '';
if (name.length === 1 || name.length === 2) return name;
return name
.split(' ')
.map((n) => n[0])
.join('');
};
const avatarStyle = cva({
base: 'relative flex items-center justify-center overflow-hidden bg-foreground-secondary/10 font-semibold text-foreground/80 shadow-[inset_0_0_0_1px_--alpha(var(--color-foreground)/8%)] backdrop-blur-sm',
variants: {
variant: {
circle: 'rounded-full',
square: 'rounded-md',
},
size: {
'2xs': 'size-4 text-2xs',
xs: 'size-6 text-2xs',
sm: 'size-8 text-xs',
md: 'size-10 text-sm',
lg: 'size-12 text-base',
xl: 'size-14 text-lg',
'2xl': 'size-16 text-xl',
'3xl': 'size-20 text-3xl',
},
},
});
interface AvatarProps extends React.ComponentPropsWithRef<'div'> {
size?: VariantProps<typeof avatarStyle>['size'];
variant?: VariantProps<typeof avatarStyle>['variant'];
}
const Avatar = ({
className,
variant = 'circle',
size = 'md',
children,
...props
}: AvatarProps) => {
return (
<div className={cn(avatarStyle({ variant, size }), className)} {...props}>
{children}
</div>
);
};
interface AvatarImageProps extends React.ComponentPropsWithRef<'img'> {
src: string;
}
const AvatarImage = ({ className, src, ...props }: AvatarImageProps) => {
return (
<img
className={cn('absolute inset-0 z-1 object-cover', className)}
src={src}
alt=""
{...props}
/>
);
};
interface AvatarFallbackProps extends React.ComponentPropsWithRef<'div'> {
children?: string;
}
const AvatarFallback = ({
className,
children,
...props
}: AvatarFallbackProps) => {
return (
<div className={cn('opacity-80', className)} {...props}>
{getInitials(children) || <UserIcon weight="bold" />}
</div>
);
};
// Tailwind spacing units for each Avatar size variant; `2xs` (size-4) is 4 spacing units, etc.
// Kept in sync with `avatarStyle.variants.size` via the `satisfies` constraint below.
const SIZE_MAP = {
'2xs': 4,
xs: 6,
sm: 8,
md: 10,
lg: 12,
xl: 14,
'2xl': 16,
'3xl': 20,
} as const satisfies Record<NonNullable<AvatarProps['size']>, number>;
const OVERLAP_UNITS = 2;
const CUTOUT_GAP_UNITS = 1;
const SQUARE_RADIUS_UNITS = 1.5;
const toPathNumber = (value: number) => {
return Number(value.toFixed(4));
};
const createCirclePath = ({
cx,
cy,
radius,
}: {
cx: number;
cy: number;
radius: number;
}) => {
const left = toPathNumber(cx - radius);
const right = toPathNumber(cx + radius);
const centerY = toPathNumber(cy);
const pathRadius = toPathNumber(radius);
return [
`M ${left} ${centerY}`,
`A ${pathRadius} ${pathRadius} 0 1 0 ${right} ${centerY}`,
`A ${pathRadius} ${pathRadius} 0 1 0 ${left} ${centerY}`,
'Z',
].join(' ');
};
const createRoundedRectPath = ({
x,
y,
width,
height,
radius,
}: {
x: number;
y: number;
width: number;
height: number;
radius: number;
}) => {
const right = x + width;
const bottom = y + height;
const pathRadius = Math.min(radius, width / 2, height / 2);
return [
`M ${toPathNumber(x + pathRadius)} ${toPathNumber(y)}`,
`H ${toPathNumber(right - pathRadius)}`,
`A ${toPathNumber(pathRadius)} ${toPathNumber(pathRadius)} 0 0 1 ${toPathNumber(right)} ${toPathNumber(y + pathRadius)}`,
`V ${toPathNumber(bottom - pathRadius)}`,
`A ${toPathNumber(pathRadius)} ${toPathNumber(pathRadius)} 0 0 1 ${toPathNumber(right - pathRadius)} ${toPathNumber(bottom)}`,
`H ${toPathNumber(x + pathRadius)}`,
`A ${toPathNumber(pathRadius)} ${toPathNumber(pathRadius)} 0 0 1 ${toPathNumber(x)} ${toPathNumber(bottom - pathRadius)}`,
`V ${toPathNumber(y + pathRadius)}`,
`A ${toPathNumber(pathRadius)} ${toPathNumber(pathRadius)} 0 0 1 ${toPathNumber(x + pathRadius)} ${toPathNumber(y)}`,
'Z',
].join(' ');
};
const createAvatarCutoutPath = ({
previousVariant,
previousSize,
currentSize,
}: {
previousVariant: NonNullable<AvatarProps['variant']>;
previousSize: NonNullable<AvatarProps['size']>;
currentSize: NonNullable<AvatarProps['size']>;
}) => {
const previousUnits = SIZE_MAP[previousSize];
const currentUnits = SIZE_MAP[currentSize];
const gap = CUTOUT_GAP_UNITS / currentUnits;
const previousWidth = previousUnits / currentUnits;
const previousCenterX = (OVERLAP_UNITS - previousUnits / 2) / currentUnits;
const previousCenterY = 0.5;
const cutout =
previousVariant === 'circle'
? createCirclePath({
cx: previousCenterX,
cy: previousCenterY,
radius: previousWidth / 2 + gap,
})
: createRoundedRectPath({
x: (OVERLAP_UNITS - previousUnits - CUTOUT_GAP_UNITS) / currentUnits,
y: (currentUnits - previousUnits) / 2 / currentUnits - gap,
width: (previousUnits + CUTOUT_GAP_UNITS * 2) / currentUnits,
height: (previousUnits + CUTOUT_GAP_UNITS * 2) / currentUnits,
radius: (SQUARE_RADIUS_UNITS + CUTOUT_GAP_UNITS) / currentUnits,
});
return `M 0 0 H 1 V 1 H 0 Z ${cutout}`;
};
const cloneAvatarForGroup = (child: React.ReactElement<AvatarProps>) => {
return cloneElement(child, {
className: cn(child.props.className, 'backdrop-blur-none'),
});
};
type AvatarGroupProps = React.ComponentPropsWithRef<'div'>;
const AvatarGroup = ({ children, className, ...props }: AvatarGroupProps) => {
const id = useId();
const items = Children.toArray(children).filter(
(child): child is React.ReactElement<AvatarProps> =>
isValidElement(child) && child.type === Avatar
);
return (
<div
className={cn(
'isolate flex items-center -space-x-(--overlap) [--overlap:--spacing(2)]',
className
)}
{...props}
>
{items.map((child, i) => {
const key = child.key ?? i;
const previous = items[i - 1];
if (!previous) return cloneAvatarForGroup(child);
const clipPathId = `${id}-${i}`;
const previousVariant = previous.props.variant ?? 'circle';
const previousSize = previous.props.size ?? 'md';
const currentSize = child.props.size ?? 'md';
const cutoutPath = createAvatarCutoutPath({
currentSize,
previousSize,
previousVariant,
});
return (
<div
key={key}
className="relative"
style={{
clipPath: `url(#${clipPathId})`,
WebkitClipPath: `url(#${clipPathId})`,
}}
>
<svg
width="0"
height="0"
className="absolute size-0 overflow-hidden"
aria-hidden="true"
focusable="false"
>
<clipPath id={clipPathId} clipPathUnits="objectBoundingBox">
<path clipRule="evenodd" fillRule="evenodd" d={cutoutPath} />
</clipPath>
</svg>
{cloneAvatarForGroup(child)}
</div>
);
})}
</div>
);
};
const CompoundAvatar = Object.assign(Avatar, {
Image: AvatarImage,
Fallback: AvatarFallback,
Group: AvatarGroup,
});
export { CompoundAvatar as Avatar }; Anatomy
<Avatar>
<Avatar.Image />
<Avatar.Fallback />
</Avatar>
API Reference
Avatar
Extends the div element.
| Prop | Default | Type |
|---|---|---|
size | "md" | "2xs""xs""sm""md""lg""xl""2xl""3xl" |
variant | "circle" | "circle""square" |
Avatar.Image
Extends the img element.
| Prop | Default | Type |
|---|---|---|
src | - | string |
Avatar.Fallback
Extends the div element.
| Prop | Default | Type |
|---|---|---|
children | - | string |
Avatar.Group
Renders direct <Avatar> children in an overlapping stack. Each non-leading avatar is cut by an SVG mask that follows the previous avatar’s variant (circle / square) and size, leaving a transparent gap. Children that aren’t <Avatar> are filtered out — wrap them only at the leaf level.
Extends the div element. No custom props.
Examples
Simple

PB
import { Avatar } from '@/components/avatar';
export default function AvatarPreview() {
return (
<Avatar>
<Avatar.Image src="https://github.com/pdrbrnd.png" />
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
);
} Fallback
PB
S
PB
import { Avatar } from '@/components/avatar';
export default function AvatarFallbackPreview() {
return (
<div className="flex flex-wrap gap-2">
{/* Full name */}
<Avatar>
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
{/* One word */}
<Avatar>
<Avatar.Fallback>Significa</Avatar.Fallback>
</Avatar>
{/* Initials */}
<Avatar>
<Avatar.Fallback>PB</Avatar.Fallback>
</Avatar>
{/* No fallback */}
<Avatar>
<Avatar.Fallback />
</Avatar>
</div>
);
} Sizes
PB
PB
PB
PB
PB
PB
PB
PB
import { Avatar } from '@/components/avatar';
export default function AvatarSizesPreview() {
return (
<div className="flex flex-wrap items-center gap-2">
<Avatar size="2xs">
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar size="xs">
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar size="sm">
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar size="md">
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar size="lg">
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar size="xl">
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar size="2xl">
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar size="3xl">
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
</div>
);
} Avatar Group

PB

PB

PB
import { Avatar } from '@/components/avatar';
export default function AvatarGroupPreview() {
return (
<Avatar.Group>
<Avatar>
<Avatar.Image src="https://github.com/pdrbrnd.png" />
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar size="2xl">
<Avatar.Image src="https://github.com/pdrbrnd.png" />
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar size="md">
<Avatar.Image src="https://github.com/pdrbrnd.png" />
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
</Avatar.Group>
);
} Avatar Group (square)

PB

PB

PB
import { Avatar } from '@/components/avatar';
export default function AvatarGroupSquarePreview() {
return (
<Avatar.Group>
<Avatar variant="square">
<Avatar.Image src="https://github.com/pdrbrnd.png" />
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar variant="square" size="2xl">
<Avatar.Image src="https://github.com/pdrbrnd.png" />
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
<Avatar variant="square" size="md">
<Avatar.Image src="https://github.com/pdrbrnd.png" />
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
</Avatar.Group>
);
} Broken Image
PB
import { Avatar } from '@/components/avatar';
export default function AvatarBrokenImagePreview() {
return (
<Avatar>
<Avatar.Image src="broken-image-url" />
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
);
} On top of media
PB
import { Avatar } from '@/components/avatar';
export default function AvatarOnTopOfMediaPreview() {
return (
<div
className="relative size-32 overflow-hidden rounded-lg bg-center bg-cover"
style={{
backgroundImage:
'url(https://images.unsplash.com/photo-1682687220742-aba13b6e50ba?q=80)',
}}
>
<div className="flex h-full items-center justify-center">
<Avatar>
<Avatar.Fallback>Pedro Brandão</Avatar.Fallback>
</Avatar>
</div>
</div>
);
} Custom color
Previous
Setup
Next
Badge