← All components

Avatar

A face over its own initials, that fades up once the photo has decoded.

+3
7 editors

The idea

The initials are always there underneath, and the photo is laid over them at nothing until it has actually loaded — so a slow image shows a calm monogram, and a broken one never shows its torn-page icon. In a group the faces overlap, each cut from the last by a ring of the page colour rather than a grey border, and the row ends on a face-sized count that names who it stands for. The status dot is ink; the word says which status.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Avatar — usage12 lines
import { Avatar, AvatarGroup } from "mihirmodi-ui";

// Initials underneath; the photo fades up once it has decoded,
// and a broken URL simply stays initials.
<Avatar src="/ada.webp" name="Ada Byron" size="lg" status="Online" />

// On a grey surface, cut the rings in that grey.
<AvatarGroup label="Editors" max={4} ring="var(--color-background-200)">
  {people.map((p) => (
    <Avatar key={p.id} name={p.name} src={p.photo} />
  ))}
</AvatarGroup>

Props

PropNotes
namestringInitials and the accessible name.
srcstringThe photo. Optional.
size"xs" | "sm" | "md" | "lg"20, 28, 36 and 48px.
statusstringDraws the ink dot; the string is what a screen reader hears.
ringstringThe colour cut round the dot and between faces — the surface it sits on.
AvatarGroup maxnumberFaces shown before the +N count.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/layout/Avatar.tsx168 lines
"use client";

import { Children, isValidElement, useCallback, useState, type CSSProperties, type ReactElement, type ReactNode } from "react";

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

export type AvatarProps = {
  /** The photo. Optional — without it, or until it loads, the initials stand in. */
  src?: string;
  /** The person's name. Their initials, and the image's alt text. */
  name: string;
  size?: AvatarSize;
  /**
   * A dot on the lower right — present, away, on a call. It is ink, not green:
   * pass the word as the value and it is what a screen reader hears.
   */
  status?: string;
  /**
   * The ring between the face and its dot (and between faces in a group):
   * the colour of whatever the avatar sits on. Default the page background.
   */
  ring?: string;
  className?: string;
};

const BOX: Record<AvatarSize, { px: number; text: string; dot: number }> = {
  xs: { px: 20, text: "text-[8.5px]", dot: 6 },
  sm: { px: 28, text: "text-[10.5px]", dot: 7 },
  md: { px: 36, text: "text-[12px]", dot: 9 },
  lg: { px: 48, text: "text-[15px]", dot: 11 },
};

/** First letter of the first and last words — "Ada Lovelace" → "AL", "Ada" → "A". */
function initials(name: string) {
  const words = name.trim().split(/\s+/).filter(Boolean);
  if (!words.length) return "";
  const first = words[0][0] ?? "";
  const last = words.length > 1 ? (words[words.length - 1][0] ?? "") : "";
  return (first + last).toUpperCase();
}

/**
 * A person, as a face or as their initials.
 *
 * The initials are always there, underneath; the photo is laid over them at
 * nothing and fades up once it has actually decoded. So a slow image shows a
 * calm monogram rather than an empty circle, and a broken one never shows its
 * torn-page icon — it simply stays initials. A cached image that finished
 * before React hydrated is caught as it mounts, so it does not wait for a load
 * event that already happened.
 *
 * Initials are set at 500 with a touch of positive tracking: at 10–12px two
 * capitals merge without it. The status dot is ink with a ring of the page
 * colour cut around it, so it reads as sitting on the face rather than as a
 * bite out of it — and it says *that* there is a status; the word says which.
 */
export function Avatar({ src, name, size = "md", status, ring = "var(--color-background-100)", className = "" }: AvatarProps) {
  // Keyed by the URL, so swapping `src` fades the new photo in rather than
  // inheriting the old one's "loaded".
  const [loaded, setLoaded] = useState<string | null>(null);
  const [broken, setBroken] = useState<string | null>(null);
  const shown = !!src && loaded === src;
  const failed = !!src && broken === src;
  const box = BOX[size];

  // A ref callback rather than an effect: it runs as the node attaches, which
  // is the moment to ask whether the browser already has the pixels.
  const probe = useCallback(
    (img: HTMLImageElement | null) => {
      if (img && src && img.complete && img.naturalWidth > 0) setLoaded(src);
    },
    [src]
  );

  return (
    <span
      className={`relative inline-flex shrink-0 select-none ${className}`}
      style={{ width: box.px, height: box.px }}
      role="img"
      aria-label={status ? `${name}, ${status}` : name}
    >
      <span
        aria-hidden
        className={`relative flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-gray-100 font-medium leading-none tracking-[0.02em] text-muted shadow-[inset_0_0_0_0.5px_var(--color-gray-alpha-400)] ${box.text}`}
      >
        {initials(name)}
        {src && !failed && (
          <img
            key={src}
            ref={probe}
            src={src}
            alt=""
            decoding="async"
            draggable={false}
            onLoad={() => setLoaded(src)}
            onError={() => setBroken(src)}
            className={`absolute inset-0 h-full w-full rounded-full object-cover transition-opacity duration-200 ease-out motion-reduce:transition-none ${
              shown ? "opacity-100" : "opacity-0"
            }`}
          />
        )}
      </span>
      {status && (
        <span
          aria-hidden
          className="absolute bottom-0 right-0 rounded-full bg-ink"
          style={{ width: box.dot, height: box.dot, boxShadow: `0 0 0 2px ${ring}` }}
        />
      )}
    </span>
  );
}

export type AvatarGroupProps = {
  /** `Avatar`s. Their size is taken from the group's, so set it once here. */
  children: ReactNode;
  /** Show this many faces, then a "+N" for the rest. */
  max?: number;
  size?: AvatarSize;
  /** The colour of the surface the group sits on — it is cut between the faces. */
  ring?: string;
  /** The group's accessible name, e.g. "Editors". */
  label?: string;
  className?: string;
};

/* A fifth of the face, no more: two initials span over half a small face, and a
   deeper overlap (plus the 2px ring) cuts into the first letter. */
const OVERLAP: Record<AvatarSize, number> = { xs: 4, sm: 6, md: 7, lg: 9 };

/**
 * A few people, overlapped, with a count for the rest.
 *
 * The faces overlap by about a fifth, each cut from the one before by a
 * ring of the page colour — a ring, not a border, because a grey border would
 * draw every face as a separate sticker. The count is a face-sized chip in the
 * same grey as the initials, so the row ends on something that looks like one
 * more person rather than a badge, and it names how many it stands for.
 */
export function AvatarGroup({ children, max = 4, size = "md", ring = "var(--color-background-100)", label, className = "" }: AvatarGroupProps) {
  const all = Children.toArray(children).filter(isValidElement) as ReactElement<AvatarProps>[];
  const shown = all.slice(0, max);
  const rest = all.length - shown.length;
  const box = BOX[size];
  const cut: CSSProperties = { boxShadow: `0 0 0 2px ${ring}`, borderRadius: 9999 };
  const hidden = all.slice(max).map((a) => a.props.name);

  return (
    <div role="group" aria-label={label} className={`flex items-center ${className}`}>
      {shown.map((a, i) => (
        <span key={a.key ?? i} className="relative inline-flex" style={{ ...cut, marginLeft: i ? -OVERLAP[size] : 0, zIndex: shown.length - i }}>
          <Avatar {...a.props} size={size} ring={ring} />
        </span>
      ))}
      {rest > 0 && (
        <span
          className={`relative inline-flex items-center justify-center rounded-full bg-gray-100 font-medium leading-none tabular-nums text-muted ${box.text}`}
          style={{ ...cut, boxShadow: `${cut.boxShadow}, inset 0 0 0 0.5px var(--color-gray-alpha-400)`, width: box.px, height: box.px, marginLeft: -OVERLAP[size] }}
          role="img"
          aria-label={`and ${rest} more: ${hidden.join(", ")}`}
          title={hidden.join(", ")}
        >
          +{rest}
        </span>
      )}
    </div>
  );
}