Data Display

Avatar

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.

Install

npx shadcn@latest add @paragon/avatar

avatar.tsx

"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>
  );
}