Avatar
Round avatar in three sizes with an optional status dot. The image fades in on load and falls back to initials, and a group stacks them and fans them out on hover.
MR
LP
AS
AK

Ma
Le
An
Sa
+2
Hover the group to fan it out.
"use client";
import { Avatar, AvatarGroup } from "@/components/motion/avatar";
// Free Unsplash portraits, loaded from Unsplash and cropped to the face.
const photo = (id: string) => `https://images.unsplash.com/photo-${id}?w=160&h=160&fit=crop&crop=faces&auto=format&q=80`;
const TEAM = [
{ name: "Maya Ross", id: "1494790108377-be9c29b29330" },
{ name: "Leo Park", id: "1500648767791-00dcc994a43e" },
{ name: "Ana Silva", id: "1580489944761-15a19d654956" },
{ name: "Sam Cole", id: "1507003211169-0a1dd7228f2d" },
{ name: "Iris Chen", id: "1607503873903-c5e95f80d7b9" },
{ name: "Tom Hale", id: "1624395213043-fa2e123b2656" },
];
export function AvatarPreview() {
return (
<div className="flex flex-col items-center gap-8">
<div className="flex items-end gap-4">
<Avatar size="sm" src={photo(TEAM[0].id)} alt={TEAM[0].name} fallback="MR" status="online" />
<Avatar src={photo(TEAM[1].id)} alt={TEAM[1].name} fallback="LP" status="away" />
<Avatar size="lg" src={photo(TEAM[2].id)} alt={TEAM[2].name} fallback="AS" status="busy" />
{/* A broken image falls back to initials. */}
<Avatar size="lg" src="/does-not-exist.png" fallback="AK" />
</div>
<div className="flex flex-col items-center gap-2">
<AvatarGroup max={4}>
{TEAM.map((person) => (
<Avatar key={person.id} src={photo(person.id)} alt={person.name} fallback={person.name.slice(0, 2)} />
))}
</AvatarGroup>
<p className="text-xs text-muted-foreground">Hover the group to fan it out.</p>
</div>
</div>
);
}
"use client";
// easeui.dev/components/motion/avatar
import { Children, type ReactNode, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export type AvatarSize = "sm" | "md" | "lg";
export type AvatarStatus = "online" | "away" | "busy";
export interface AvatarProps {
src?: string;
alt?: string;
/** Shown while there is no image, or once it fails to load. */
fallback: string;
/** Default "md". */
size?: AvatarSize;
/** A small dot in the corner for presence. Leave unset for none. */
status?: AvatarStatus;
className?: string;
}
const SIZES: Record<AvatarSize, { box: string; text: string; dot: string }> = {
sm: { box: "h-8 w-8", text: "text-xs", dot: "h-2.5 w-2.5" },
md: { box: "h-10 w-10", text: "text-sm", dot: "h-3 w-3" },
lg: { box: "h-14 w-14", text: "text-base", dot: "h-3.5 w-3.5" },
};
const STATUS: Record<AvatarStatus, { color: string; label: string }> = {
online: { color: "bg-success", label: "Online" },
away: { color: "bg-warning", label: "Away" },
busy: { color: "bg-destructive", label: "Busy" },
};
/** A round avatar. The image fades in on load and crossfades to the fallback if it fails. */
export function Avatar({ src, alt = "", fallback, size = "md", status, className }: AvatarProps) {
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
const img = useRef<HTMLImageElement>(null);
const showImage = Boolean(src) && !errored;
// A cached image can finish before React attaches onLoad, so check once it mounts.
useEffect(() => {
const node = img.current;
if (!node?.complete) return;
if (node.naturalWidth > 0) setLoaded(true);
else setErrored(true);
}, []);
return (
<span className={cn("relative inline-flex shrink-0", SIZES[size].box, className)}>
<span
className={cn(
"relative inline-flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground shadow-[0_0_0_1px_var(--border)]",
SIZES[size].text,
)}
>
<span
aria-hidden={showImage && loaded}
className={cn("transition-opacity duration-150 ease-out", showImage && loaded ? "opacity-0" : "opacity-100")}
>
{fallback}
</span>
{showImage ? (
// A plain img, not next/image: this ships as source a consumer copies
// into their own app, which may not have next/image configured.
// biome-ignore lint/performance/noImgElement: portable across non-Next apps.
<img
ref={img}
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
onError={() => setErrored(true)}
className={cn(
"absolute inset-0 h-full w-full object-cover opacity-0 transition-opacity duration-150 ease-out",
loaded && "opacity-100",
)}
/>
) : null}
{/* A faint inner edge, so a pale photo still has a shape against the page. */}
<span aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_var(--border-strong)]" />
</span>
{status ? (
<span
role="img"
aria-label={STATUS[status].label}
className={cn(
"absolute bottom-0 right-0 rounded-full shadow-[0_0_0_2px_var(--background)]",
SIZES[size].dot,
STATUS[status].color,
)}
/>
) : null}
</span>
);
}
export interface AvatarGroupProps {
/** Avatars to stack. */
children: ReactNode;
/** How many to show before the rest fold into a "+N" chip. Default 4. */
max?: number;
/** Matches the avatars inside. Default "md". */
size?: AvatarSize;
className?: string;
}
const OVERLAP: Record<AvatarSize, string> = { sm: "-ml-2.5", md: "-ml-3", lg: "-ml-4" };
// How far each avatar slides right when the group fans out, per position.
const SPREAD: Record<AvatarSize, number> = { sm: 6, md: 8, lg: 10 };
/** Overlapping avatars that fan apart on hover so every face shows. Anything past `max` becomes a "+N" chip. */
export function AvatarGroup({ children, max = 4, size = "md", className }: AvatarGroupProps) {
const items = Children.toArray(children);
const shown = items.slice(0, max);
const rest = items.length - shown.length;
const all = rest > 0 ? [...shown, <OverflowChip key="rest" count={rest} size={size} />] : shown;
return (
<div className={cn("group/avatars flex items-center", className)}>
{all.map((child, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed stack, ordered by the caller.
key={index}
className={cn("relative rounded-full shadow-[0_0_0_2px_var(--background)]", index > 0 && OVERLAP[size])}
style={{ zIndex: all.length - index, ["--spread" as string]: `${index * SPREAD[size]}px` }}
>
<span className="block transition-transform duration-200 ease-out group-hover/avatars:translate-x-[var(--spread)] motion-reduce:transition-none">
{child}
</span>
</span>
))}
</div>
);
}
function OverflowChip({ count, size }: { count: number; size: AvatarSize }) {
return (
<span
className={cn(
"inline-flex items-center justify-center rounded-full bg-muted font-medium tabular-nums text-muted-foreground shadow-[0_0_0_1px_var(--border)]",
SIZES[size].box,
SIZES[size].text,
)}
>
+{count}
</span>
);
}
Installation
$ bunx --bun shadcn add @easeui/avatar
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx tailwind-merge - 3
Add the source files
components/motion/avatar.tsx "use client"; // easeui.dev/components/motion/avatar import { Children, type ReactNode, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type AvatarSize = "sm" | "md" | "lg"; export type AvatarStatus = "online" | "away" | "busy"; export interface AvatarProps { src?: string; alt?: string; /** Shown while there is no image, or once it fails to load. */ fallback: string; /** Default "md". */ size?: AvatarSize; /** A small dot in the corner for presence. Leave unset for none. */ status?: AvatarStatus; className?: string; } const SIZES: Record<AvatarSize, { box: string; text: string; dot: string }> = { sm: { box: "h-8 w-8", text: "text-xs", dot: "h-2.5 w-2.5" }, md: { box: "h-10 w-10", text: "text-sm", dot: "h-3 w-3" }, lg: { box: "h-14 w-14", text: "text-base", dot: "h-3.5 w-3.5" }, }; const STATUS: Record<AvatarStatus, { color: string; label: string }> = { online: { color: "bg-success", label: "Online" }, away: { color: "bg-warning", label: "Away" }, busy: { color: "bg-destructive", label: "Busy" }, }; /** A round avatar. The image fades in on load and crossfades to the fallback if it fails. */ export function Avatar({ src, alt = "", fallback, size = "md", status, className }: AvatarProps) { const [loaded, setLoaded] = useState(false); const [errored, setErrored] = useState(false); const img = useRef<HTMLImageElement>(null); const showImage = Boolean(src) && !errored; // A cached image can finish before React attaches onLoad, so check once it mounts. useEffect(() => { const node = img.current; if (!node?.complete) return; if (node.naturalWidth > 0) setLoaded(true); else setErrored(true); }, []); return ( <span className={cn("relative inline-flex shrink-0", SIZES[size].box, className)}> <span className={cn( "relative inline-flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground shadow-[0_0_0_1px_var(--border)]", SIZES[size].text, )} > <span aria-hidden={showImage && loaded} className={cn("transition-opacity duration-150 ease-out", showImage && loaded ? "opacity-0" : "opacity-100")} > {fallback} </span> {showImage ? ( // A plain img, not next/image: this ships as source a consumer copies // into their own app, which may not have next/image configured. // biome-ignore lint/performance/noImgElement: portable across non-Next apps. <img ref={img} src={src} alt={alt} onLoad={() => setLoaded(true)} onError={() => setErrored(true)} className={cn( "absolute inset-0 h-full w-full object-cover opacity-0 transition-opacity duration-150 ease-out", loaded && "opacity-100", )} /> ) : null} {/* A faint inner edge, so a pale photo still has a shape against the page. */} <span aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_var(--border-strong)]" /> </span> {status ? ( <span role="img" aria-label={STATUS[status].label} className={cn( "absolute bottom-0 right-0 rounded-full shadow-[0_0_0_2px_var(--background)]", SIZES[size].dot, STATUS[status].color, )} /> ) : null} </span> ); } export interface AvatarGroupProps { /** Avatars to stack. */ children: ReactNode; /** How many to show before the rest fold into a "+N" chip. Default 4. */ max?: number; /** Matches the avatars inside. Default "md". */ size?: AvatarSize; className?: string; } const OVERLAP: Record<AvatarSize, string> = { sm: "-ml-2.5", md: "-ml-3", lg: "-ml-4" }; // How far each avatar slides right when the group fans out, per position. const SPREAD: Record<AvatarSize, number> = { sm: 6, md: 8, lg: 10 }; /** Overlapping avatars that fan apart on hover so every face shows. Anything past `max` becomes a "+N" chip. */ export function AvatarGroup({ children, max = 4, size = "md", className }: AvatarGroupProps) { const items = Children.toArray(children); const shown = items.slice(0, max); const rest = items.length - shown.length; const all = rest > 0 ? [...shown, <OverflowChip key="rest" count={rest} size={size} />] : shown; return ( <div className={cn("group/avatars flex items-center", className)}> {all.map((child, index) => ( <span // biome-ignore lint/suspicious/noArrayIndexKey: a fixed stack, ordered by the caller. key={index} className={cn("relative rounded-full shadow-[0_0_0_2px_var(--background)]", index > 0 && OVERLAP[size])} style={{ zIndex: all.length - index, ["--spread" as string]: `${index * SPREAD[size]}px` }} > <span className="block transition-transform duration-200 ease-out group-hover/avatars:translate-x-[var(--spread)] motion-reduce:transition-none"> {child} </span> </span> ))} </div> ); } function OverflowChip({ count, size }: { count: number; size: AvatarSize }) { return ( <span className={cn( "inline-flex items-center justify-center rounded-full bg-muted font-medium tabular-nums text-muted-foreground shadow-[0_0_0_1px_var(--border)]", SIZES[size].box, SIZES[size].text, )} > +{count} </span> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
| Avatar | src? | string | - | - |
| Avatar | alt? | string | - | |
| Avatar | fallback | string | - | Shown while there is no image, or once it fails to load. |
| Avatar | size? | "sm" | "md" | "lg" | md | Default "md". |
| Avatar | status? | "online" | "away" | "busy" | - | A small dot in the corner for presence. Leave unset for none. |
| Avatar | className? | string | - | - |
| AvatarGroup | children | ReactNode | - | Avatars to stack. |
| AvatarGroup | max? | number | 4 | How many to show before the rest fold into a "+N" chip. Default 4. |
| AvatarGroup | size? | "sm" | "md" | "lg" | md | Matches the avatars inside. Default "md". |
| AvatarGroup | className? | string | - | - |