Loading...
Loading...
A horizontal list of user avatars with name labels, supporting image fallback to initials.
import { useState } from "react";
interface AvatarListItem {
src?: string;
name: string;
}
interface AvatarListProps {
users: AvatarListItem[];
size?: "sm" | "md" | "lg";
}
const sizeMap = {
sm: { img: "w-8 h-8", text: "text-[10px]", name: "text-xs" },
md: { img: "w-10 h-10", text: "text-xs", name: "text-sm" },
lg: { img: "w-14 h-14", text: "text-sm", name: "text-base" },
};
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return parts[0]?.slice(0, 2).toUpperCase() || "?";
}
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
return `hsl(${hash % 360}, 55%, 55%)`;
}
export function AvatarList({ users, size = "md" }: AvatarListProps) {
const s = sizeMap[size];
return (
<div className="flex flex-col gap-3 p-6 bg-white rounded-2xl max-w-sm mx-auto">
{users.map((user, i) => (
<AvatarListItemInternal key={i} user={user} imgClass={s.img} textClass={s.text} nameClass={s.name} />
))}
</div>
);
}
function AvatarListItemInternal({ user, imgClass, textClass, nameClass }: { user: AvatarListItem; imgClass: string; textClass: string; nameClass: string }) {
const [imgError, setImgError] = useState(false);
return (
<div className="flex items-center gap-3">
{user.src && !imgError ? (
<img src={user.src} alt={user.name} onError={() => setImgError(true)} className={`${imgClass} rounded-full object-cover ring-2 ring-white shadow-sm`} />
) : (
<div className={`${imgClass} rounded-full flex items-center justify-center font-bold text-white ring-2 ring-white shrink-0 ${textClass}`} style={{ backgroundColor: stringToColor(user.name) }}>
{getInitials(user.name)}
</div>
)}
<span className={`font-medium text-gray-800 ${nameClass}`}>{user.name}</span>
</div>
);
}