Give Avatar initials, person-mark, and load-error fallbacks so missing photos still look like the design-system atom.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilallo
2026-08-26 13:18:33 -06:00
co-authored by Cursor
parent 95781452bd
commit 4c4cf99572
6 changed files with 395 additions and 29 deletions
+164 -25
View File
@@ -1,39 +1,178 @@
import { memo } from "react";
"use client";
export type AvatarSizeValue = "small" | "medium" | "large" | "xlarge";
/**
* Figma: "Asset / Avatar" (18628:32840). Circular (or rounded-square) image
* with initials or a person mark when `src` is missing or fails to load.
*/
interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
import { memo, useLayoutEffect, useRef, useState } from "react";
import type {
AvatarShapeValue,
AvatarSizeValue,
AvatarVariantValue,
} from "../../../../lib/propNormalization";
export type { AvatarShapeValue, AvatarSizeValue, AvatarVariantValue };
export interface AvatarProps {
src?: string;
alt: string;
size?: AvatarSizeValue;
/** 12 characters, or a name (first letters of the first two words). */
initials?: string;
shape?: AvatarShapeValue;
/** Fallback chrome: cream fill (default) or yellow outline. */
variant?: AvatarVariantValue;
className?: string;
onClick?: () => void;
}
const SIZE_CLASS: Record<AvatarSizeValue, string> = {
small:
"h-[var(--spacing-scale-016)] w-[var(--spacing-scale-016)] text-xx-small-label",
medium:
"h-[var(--spacing-scale-018)] w-[var(--spacing-scale-018)] text-xx-small-label",
large:
"h-[var(--spacing-scale-024)] w-[var(--spacing-scale-024)] text-xx-small-label",
xlarge:
"h-[var(--spacing-scale-032)] w-[var(--spacing-scale-032)] text-x-small-label",
};
function formatAvatarInitials(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
const words = trimmed.split(/\s+/);
if (words.length >= 2) {
return `${words[0]?.[0] ?? ""}${words[1]?.[0] ?? ""}`.toUpperCase();
}
return trimmed.slice(0, 2).toUpperCase();
}
function AvatarPersonIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-1/2 w-1/2"
aria-hidden
focusable="false"
>
<path
fill="currentColor"
d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8V21h19.2v-1.8c0-3.2-6.4-4.8-9.6-4.8z"
/>
</svg>
);
}
const Avatar = memo<AvatarProps>(
({ src, alt, size: sizeProp = "small", className = "", ...props }) => {
const size = sizeProp;
const sizeStyles: Record<string, string> = {
small:
// White 30% border: no DS token (opacity scale is black-alpha; inverse border tokens don't cover this).
"w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)] border-[1.5px] border-[#FFFFFF4D] border-solid",
medium: "w-[var(--spacing-scale-018)] h-[var(--spacing-scale-018)]",
large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]",
xlarge: "w-[var(--spacing-scale-032)] h-[var(--spacing-scale-032)]",
};
({
src,
alt,
size = "small",
initials,
shape = "circle",
variant = "filled",
className = "",
onClick,
}) => {
const imgRef = useRef<HTMLImageElement>(null);
const [failed, setFailed] = useState(false);
const [loaded, setLoaded] = useState(false);
const trimmedSrc = src?.trim() ?? "";
const hasSrc = trimmedSrc.length > 0;
const showImage = hasSrc && !failed;
const mark = initials ? formatAvatarInitials(initials) : "";
const decorative = alt.trim().length === 0;
const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover box-border ${sizeStyles[size]} ${className}`;
useLayoutEffect(() => {
setFailed(false);
const img = imgRef.current;
setLoaded(Boolean(img?.complete && img.naturalWidth > 0));
}, [trimmedSrc]);
const radiusClass =
shape === "rounded-square"
? "rounded-[var(--measures-radius-200,8px)]"
: "rounded-[var(--radius-measures-radius-full)]";
const fallbackChrome =
variant === "outlined"
? "border-[1.5px] border-solid border-[var(--color-content-default-brand-primary)] bg-transparent text-[var(--color-content-default-brand-primary)]"
: "bg-[var(--color-surface-invert-brand-primary)] text-[var(--color-content-default-primary)]";
// White 30% ring on small photos: no DS token (opacity scale is black-alpha).
const smallImageBorder =
size === "small" && showImage && loaded
? "border-[1.5px] border-solid border-[#FFFFFF4D]"
: "";
const interactiveClass = onClick
? `cursor-pointer appearance-none bg-transparent p-0 ${smallImageBorder ? "" : "border-0"} transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]`
: "";
const shellClass =
`relative inline-flex box-border shrink-0 items-center justify-center overflow-hidden ${SIZE_CLASS[size]} ${radiusClass} ${smallImageBorder} ${interactiveClass} ${className}`.trim();
const showFallback = !showImage;
const showSkeleton = showImage && !loaded;
const visual = (
<>
{showFallback ? (
<span
className={`absolute inset-0 flex items-center justify-center font-medium ${fallbackChrome} ${radiusClass}`}
>
{mark ? mark : <AvatarPersonIcon />}
</span>
) : null}
{showSkeleton ? (
<span
className={`absolute inset-0 animate-pulse bg-[var(--color-surface-default-tertiary)] ${radiusClass}`}
aria-hidden
/>
) : null}
{showImage ? (
/* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */
<img
ref={imgRef}
src={trimmedSrc}
alt={onClick ? "" : alt}
className={`relative z-[1] h-full w-full object-cover ${radiusClass} ${loaded ? "opacity-100" : "opacity-0"}`}
loading="eager"
decoding="async"
fetchPriority="high"
onLoad={() => setLoaded(true)}
onError={() => {
setFailed(true);
setLoaded(false);
}}
/>
) : null}
</>
);
if (onClick) {
return (
<button
type="button"
data-figma-node="18628-32840"
className={shellClass}
onClick={onClick}
aria-label={decorative ? undefined : alt}
>
{visual}
</button>
);
}
return (
/* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */
<img
src={src}
alt={alt}
className={baseStyles}
loading="eager"
decoding="async"
fetchPriority="high"
{...props}
/>
<span
data-figma-node="18628-32840"
className={shellClass}
{...(showFallback
? decorative
? { "aria-hidden": true as const }
: { role: "img" as const, "aria-label": alt }
: {})}
>
{visual}
</span>
);
},
);
+6 -1
View File
@@ -1,2 +1,7 @@
export { default } from "./Avatar";
export type { AvatarSizeValue } from "./Avatar";
export type { AvatarProps } from "./Avatar";
export type {
AvatarShapeValue,
AvatarSizeValue,
AvatarVariantValue,
} from "./Avatar";