Avatar

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

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

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