A Radix avatar whose image fades in on load, with deterministic pastel initials hashed from the name, sizes, a status-dot slot, and a stacking ring for groups.
npx shadcn@latest add @paragon/avatar"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
export const avatarSizes = {
sm: "size-6 text-[10px]",
default: "size-8 text-xs",
lg: "size-10 text-sm",
xl: "size-12 text-base",
} as const;
export type AvatarSize = keyof typeof avatarSizes;
/** Stable 32-bit hash so the same name always maps to the same pastel. */
function hashString(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
/** "Priya Raghavan" → "PR"; single names take their first two letters. */
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "•";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
export interface AvatarProps
extends React.ComponentProps<typeof AvatarPrimitive.Root> {
size?: AvatarSize;
/** Ring in the page background — the boundary between stacked avatars. */
ring?: boolean;
}
/**
* A Radix avatar. The root stays un-clipped so a status dot can overhang
* the circle; the image and fallback each round themselves.
*/
export function Avatar({
size = "default",
ring = false,
className,
...props
}: AvatarProps) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex shrink-0 rounded-full select-none",
avatarSizes[size],
ring && "ring-2 ring-background",
className,
)}
{...props}
/>
);
}
export interface AvatarImageProps
extends React.ComponentProps<typeof AvatarPrimitive.Image> {}
/**
* The image fades in once loaded instead of popping — an opacity-only
* move, so it also runs under reduced motion.
*/
export function AvatarImage({
className,
onLoadingStatusChange,
...props
}: AvatarImageProps) {
const [loaded, setLoaded] = React.useState(false);
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
onLoadingStatusChange={(status) => {
setLoaded(status === "loaded");
onLoadingStatusChange?.(status);
}}
className={cn(
"aspect-square size-full rounded-full object-cover transition-opacity duration-(--duration-base) ease-(--ease-out)",
loaded ? "opacity-100" : "opacity-0",
className,
)}
{...props}
/>
);
}
export interface AvatarFallbackProps
extends React.ComponentProps<typeof AvatarPrimitive.Fallback> {
/** Seeds deterministic initials and a pastel tone from a hash — no randomness. */
name?: string;
}
export function AvatarFallback({
name,
className,
style,
children,
...props
}: AvatarFallbackProps) {
const hue = name ? hashString(name) % 360 : null;
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
style={
{
...(hue !== null && {
"--avatar-pastel-bg": `oklch(0.93 0.045 ${hue})`,
"--avatar-pastel-fg": `oklch(0.42 0.085 ${hue})`,
"--avatar-pastel-bg-dark": `oklch(0.33 0.05 ${hue})`,
"--avatar-pastel-fg-dark": `oklch(0.87 0.06 ${hue})`,
}),
...style,
} as React.CSSProperties
}
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted font-medium text-muted-foreground",
hue !== null &&
"bg-(--avatar-pastel-bg) text-(--avatar-pastel-fg) dark:bg-(--avatar-pastel-bg-dark) dark:text-(--avatar-pastel-fg-dark)",
className,
)}
{...props}
>
{children ?? (name ? getInitials(name) : null)}
</AvatarPrimitive.Fallback>
);
}
const statusStyles = {
online: "bg-emerald-500",
away: "bg-amber-500",
busy: "bg-red-500",
offline: "bg-muted-foreground/50",
} as const;
export type AvatarStatusValue = keyof typeof statusStyles;
export interface AvatarStatusProps extends React.ComponentProps<"span"> {
status?: AvatarStatusValue;
}
/**
* Presence dot slotted into the avatar's bottom-right corner, ringed in
* the background color so it reads as sitting on top of the circle.
*/
export function AvatarStatus({
status = "online",
className,
...props
}: AvatarStatusProps) {
return (
<span
data-slot="avatar-status"
className={cn(
"absolute right-0 bottom-0 block size-2.5 rounded-full ring-2 ring-background",
statusStyles[status],
className,
)}
{...props}
>
<span className="sr-only">{status}</span>
</span>
);
}