Avatar

Initials or image avatar with stable colour hashing and size variants.

import { Avatar, AvatarProps }from "@/components/ui"

Specification

Represents a person or entity with an image, or initials with stable color hashing when no image is available.

Props (4)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
name*string—Full name used to derive the initials and (when colourful) the hashed color.
srcstring—Optional image URL; falls back to initials if it fails to load.
size"sm" | "md" | "lg""md"Avatar diameter and font size.
colourfulboolean—Assigns a stable color from the palette based on the name instead of a neutral tone.

Sizes & colours

AWBODNFK
Full source
The complete component, read from src/components/ui/avatar.tsx.
avatar.tsx
"use client";

export type AvatarSize = "sm" | "md" | "lg";

export interface AvatarProps {

  name: string;

  src?: string;
  size?: AvatarSize;

  colourful?: boolean;
}

const SIZE_PX: Record<AvatarSize, number> = { sm: 24, md: 32, lg: 40 };
const FONT_PX: Record<AvatarSize, number> = { sm: 10, md: 12, lg: 14 };

const PALETTES: Array<{ bg: string; fg: string }> = [
  { bg: "var(--color-accent-light)", fg: "var(--color-accent)" },
  { bg: "var(--color-warning-light)", fg: "var(--color-warning)" },
  { bg: "var(--color-error-light)", fg: "var(--color-error)" },
  { bg: "var(--avatar-1-bg)", fg: "var(--avatar-1-fg)" },
  { bg: "var(--avatar-2-bg)", fg: "var(--avatar-2-fg)" },
  { bg: "var(--avatar-3-bg)", fg: "var(--avatar-3-fg)" },
];

function hashName(name: string): number {
  let h = 0;
  for (let i = 0; i < name.length; i++) h = ((h * 31) + name.charCodeAt(i)) >>> 0;
  return h % PALETTES.length;
}

function initialsOf(name: string): string {
  const parts = name.trim().split(/\s+/);
  return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}

export function Avatar({ name, src, size = "md", colourful }: AvatarProps) {
  const px = SIZE_PX[size];
  const fs = FONT_PX[size];
  const palette = colourful ? PALETTES[hashName(name)] : { bg: "var(--color-bg-secondary)", fg: "var(--color-text-secondary)" };

  if (src) {
    return (

      <img
        src={src}
        alt={name}
        width={px}
        height={px}
        className="shrink-0 rounded-full object-cover"
        style={{ width: px, height: px }}
        onError={(e) => {

          const img = e.currentTarget;
          const span = document.createElement("span");
          span.setAttribute("aria-label", name);
          span.style.cssText = `display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;font-weight:500;width:${px}px;height:${px}px;font-size:${fs}px;background:${palette.bg};color:${palette.fg};flex-shrink:0`;
          span.textContent = initialsOf(name);
          img.replaceWith(span);
        }}
      />
    );
  }

  return (
    <span
      aria-label={name}
      className="inline-flex shrink-0 items-center justify-center rounded-full font-medium"
      style={{ width: px, height: px, fontSize: fs, background: palette.bg, color: palette.fg }}
    >
      {initialsOf(name)}
    </span>
  );
}