Avatar
Muestra la foto de perfil de un usuario con una alternativa de iniciales cuando no hay imagen disponible, comúnmente se utiliza en barras de navegación, hilos de comentarios y listas de usuarios.
Casos de uso
- Mostrar fotos de perfil de usuarios en encabezados de navegación
- Mostrar avatares de comentaristas en hilos de discusión
- Representar miembros del equipo en un panel de control del proyecto
- Mostrar fotos del remitente en interfaces de chat o mensajería
- Mostrar participantes en una sala de espera de videollamada
- Indicar estado en línea/desconectado junto con la identidad del usuario
- Apilar avatares superpuestos para representar un grupo de usuarios
Implementación más simple
"use client";
interface AvatarProps {
src?: string;
name: string;
}
export function Avatar({ src, name }: AvatarProps) {
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return src ? (
<img
src={src}
alt={name}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-sm font-medium text-white">
{initials}
</div>
);
}Renderiza una imagen cuando se proporciona src, de lo contrario se revierte a iniciales extraídas del nombre del usuario. La clase object-cover evita la distorsión de imagen en fotos no cuadradas.
Variaciones
Avatar de imagen con alternativa en error
"use client";
import { useState } from "react";
interface AvatarProps {
src?: string;
name: string;
}
export function Avatar({ src, name }: AvatarProps) {
const [failed, setFailed] = useState(false);
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
if (!src || failed) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-sm font-medium text-white">
{initials}
</div>
);
}
return (
<img
src={src}
alt={name}
onError={() => setFailed(true)}
className="h-10 w-10 rounded-full object-cover"
/>
);
}Maneja elegantemente URLs de imagen rotos capturando el evento onError y revirtiendo a iniciales. Sin esto, una imagen rota muestra el icono de imagen rota predeterminado del navegador.
Iniciales con variantes de color
"use client";
interface AvatarProps {
name: string;
}
const colors = [
"bg-red-600",
"bg-orange-600",
"bg-amber-600",
"bg-green-600",
"bg-teal-600",
"bg-blue-600",
"bg-indigo-600",
"bg-purple-600",
"bg-pink-600",
];
function getColorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
export function Avatar({ name }: AvatarProps) {
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<div
className={`flex h-10 w-10 items-center justify-center rounded-full text-sm font-medium text-white ${getColorFromName(name)}`}
>
{initials}
</div>
);
}Asigna un color de fondo consistente basado en un hash del nombre del usuario. El mismo nombre siempre produce el mismo color, lo que facilita distinguir visualmente a los usuarios en una lista.
Con indicador de estado
"use client";
type Status = "online" | "offline" | "busy" | "away";
interface AvatarProps {
src?: string;
name: string;
status?: Status;
}
const statusColors: Record<Status, string> = {
online: "bg-green-500",
offline: "bg-gray-400",
busy: "bg-red-500",
away: "bg-yellow-500",
};
export function Avatar({ src, name, status }: AvatarProps) {
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<div className="relative inline-flex">
{src ? (
<img src={src} alt={name} className="h-10 w-10 rounded-full object-cover" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-sm font-medium text-white">
{initials}
</div>
)}
{status && (
<span
className={`absolute bottom-0 right-0 h-3 w-3 rounded-full ring-2 ring-white ${statusColors[status]}`}
aria-label={status}
/>
)}
</div>
);
}Un punto de color posicionado en la esquina inferior derecha indica el estado del usuario. ring-2 ring-white crea un borde blanco alrededor del punto para que destaque contra la imagen del avatar.
Variantes de tamaño
"use client";
type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
interface AvatarProps {
src?: string;
name: string;
size?: AvatarSize;
}
const sizeClasses: Record<AvatarSize, { container: string; text: string }> = {
xs: { container: "h-6 w-6", text: "text-[10px]" },
sm: { container: "h-8 w-8", text: "text-xs" },
md: { container: "h-10 w-10", text: "text-sm" },
lg: { container: "h-14 w-14", text: "text-lg" },
xl: { container: "h-20 w-20", text: "text-2xl" },
};
export function Avatar({ src, name, size = "md" }: AvatarProps) {
const sizes = sizeClasses[size];
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return src ? (
<img src={src} alt={name} className={`rounded-full object-cover ${sizes.container}`} />
) : (
<div
className={`flex items-center justify-center rounded-full bg-blue-600 font-medium text-white ${sizes.container} ${sizes.text}`}
>
{initials}
</div>
);
}Cinco presets de tamaño de xs (24px) a xl (80px). El tamaño de fuente se escala proporcionalmente con el contenedor para que las iniciales permanezcan legibles en todos los tamaños.
Grupo de avatares / Pila
"use client";
interface AvatarGroupProps {
users: { name: string; src?: string }[];
max?: number;
}
export function AvatarGroup({ users, max = 4 }: AvatarGroupProps) {
const visible = users.slice(0, max);
const remaining = users.length - max;
return (
<div className="flex -space-x-2">
{visible.map((user) => {
const initials = user.name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return user.src ? (
<img
key={user.name}
src={user.src}
alt={user.name}
className="h-8 w-8 rounded-full border-2 border-white object-cover"
/>
) : (
<div
key={user.name}
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-white bg-blue-600 text-xs font-medium text-white"
>
{initials}
</div>
);
})}
{remaining > 0 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-white bg-gray-200 text-xs font-medium text-gray-600">
+{remaining}
</div>
)}
</div>
);
}Usa -space-x-2 para superponer avatares horizontalmente. Un border-2 border-white en cada avatar crea separación visual. Cuando el número de usuarios excede max, una insignia "+N" muestra el contador de desbordamiento.
Con insignia
"use client";
interface AvatarProps {
src?: string;
name: string;
badge?: string | number;
}
export function Avatar({ src, name, badge }: AvatarProps) {
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<div className="relative inline-flex">
{src ? (
<img src={src} alt={name} className="h-10 w-10 rounded-full object-cover" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-600 text-sm font-medium text-white">
{initials}
</div>
)}
{badge !== undefined && (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white ring-2 ring-white">
{badge}
</span>
)}
</div>
);
}Una insignia de notificación posicionada en la esquina superior derecha. min-w-5 asegura que la insignia permanezca circular para números de un dígito mientras se expande para números más grandes. ring-2 ring-white separa la insignia del avatar visualmente.
Implementación compleja
"use client";
import { forwardRef, useState, useMemo } from "react";
import Image from "next/image";
type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
type Status = "online" | "offline" | "busy" | "away";
interface AvatarProps {
src?: string;
name: string;
size?: AvatarSize;
status?: Status;
badge?: string | number;
rounded?: "full" | "lg";
className?: string;
}
const sizeConfig: Record<AvatarSize, { px: number; container: string; text: string; status: string; badge: string }> = {
xs: { px: 24, container: "h-6 w-6", text: "text-[10px]", status: "h-2 w-2 ring-1", badge: "h-3.5 min-w-3.5 text-[8px] -right-0.5 -top-0.5" },
sm: { px: 32, container: "h-8 w-8", text: "text-xs", status: "h-2.5 w-2.5 ring-2", badge: "h-4 min-w-4 text-[9px] -right-1 -top-1" },
md: { px: 40, container: "h-10 w-10", text: "text-sm", status: "h-3 w-3 ring-2", badge: "h-5 min-w-5 text-[10px] -right-1 -top-1" },
lg: { px: 56, container: "h-14 w-14", text: "text-lg", status: "h-3.5 w-3.5 ring-2", badge: "h-5 min-w-5 text-[10px] -right-0.5 -top-0.5" },
xl: { px: 80, container: "h-20 w-20", text: "text-2xl", status: "h-4 w-4 ring-2", badge: "h-6 min-w-6 text-xs -right-1 -top-1" },
};
const statusColors: Record<Status, string> = {
online: "bg-green-500",
offline: "bg-gray-400",
busy: "bg-red-500",
away: "bg-yellow-500",
};
const bgColors = [
"bg-red-600", "bg-orange-600", "bg-amber-600", "bg-green-600",
"bg-teal-600", "bg-blue-600", "bg-indigo-600", "bg-purple-600", "bg-pink-600",
];
function hashName(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash);
}
export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(function Avatar(
{ src, name, size = "md", status, badge, rounded = "full", className },
ref
) {
const [imgFailed, setImgFailed] = useState(false);
const config = sizeConfig[size];
const roundedClass = rounded === "full" ? "rounded-full" : "rounded-lg";
const initials = useMemo(
() =>
name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase(),
[name]
);
const bgColor = useMemo(() => bgColors[hashName(name) % bgColors.length], [name]);
return (
<div ref={ref} className={`relative inline-flex shrink-0 ${className ?? ""}`}>
{src && !imgFailed ? (
<Image
src={src}
alt={name}
width={config.px}
height={config.px}
onError={() => setImgFailed(true)}
className={`${config.container} ${roundedClass} object-cover`}
/>
) : (
<div
className={`flex items-center justify-center font-medium text-white ${config.container} ${config.text} ${roundedClass} ${bgColor}`}
role="img"
aria-label={name}
>
{initials}
</div>
)}
{status && (
<span
className={`absolute bottom-0 right-0 ${roundedClass === "rounded-full" ? "rounded-full" : "rounded-full"} ring-white ${config.status} ${statusColors[status]}`}
aria-label={`Status: ${status}`}
/>
)}
{badge !== undefined && (
<span
className={`absolute flex items-center justify-center rounded-full bg-red-500 px-0.5 font-bold text-white ring-2 ring-white ${config.badge}`}
>
{typeof badge === "number" && badge > 99 ? "99+" : badge}
</span>
)}
</div>
);
});
// --- Avatar Group ---
interface AvatarGroupProps {
users: { name: string; src?: string }[];
max?: number;
size?: AvatarSize;
}
export function AvatarGroup({ users, max = 4, size = "sm" }: AvatarGroupProps) {
const visible = users.slice(0, max);
const remaining = users.length - max;
const config = sizeConfig[size];
return (
<div className="flex -space-x-2" role="group" aria-label={`${users.length} users`}>
{visible.map((user) => (
<Avatar
key={user.name}
src={user.src}
name={user.name}
size={size}
className="border-2 border-white"
/>
))}
{remaining > 0 && (
<div
className={`flex items-center justify-center rounded-full border-2 border-white bg-gray-200 font-medium text-gray-600 ${config.container} ${config.text}`}
>
+{remaining}
</div>
)}
</div>
);
}Aspectos clave:
- Optimización de Next.js Image -- usa
next/imagepara cambio de tamaño automático, carga perezosa y conversión de formato. Se revierte a iniciales en error. - Hash determinístico de color -- el mismo nombre de usuario siempre produce el mismo color de fondo, proporcionando consistencia visual en toda la aplicación sin almacenar preferencias de color.
- Indicadores escalados -- los tamaños de los puntos de estado e insignias son proporcionales al tamaño del avatar, por lo que todo se ve correcto de
xsaxl. - forwardRef -- permite a los componentes padres medir, posicionar o animar el avatar con refs.
- Desbordamiento de insignia -- los números superiores a 99 se limitan a "99+" para evitar que la insignia crezca demasiado y rompa el diseño.
- role="img" en alternativa -- el div de iniciales se marca con un rol de imagen con
aria-labelpara que los lectores de pantalla anuncien el nombre completo del usuario en lugar de leer letras individuales. - shrink-0 -- evita que el avatar se encoja dentro de contenedores flex, que es un problema de diseño común en barras laterales y listas de usuarios.
Trampa
-
Falta de
alten imágenes -- Omitir el atributoalthace que el avatar sea invisible para los lectores de pantalla. Siempre establecealtal nombre del usuario. -
Distorsión de relación de aspecto de imagen -- Las imágenes de perfil no cuadradas se estiran sin
object-cover. Siempre usaobject-coveren imágenes de avatar redondeadas. -
Iniciales de cadenas vacías --
"".split(" ")devuelve[""], y""[0]esundefined. Protege contra nombres vacíos o indefinidos antes de extraer iniciales. -
Punto de estado invisible en fondos oscuros -- Un punto "en línea" verde desaparece contra un avatar verde. El borde
ring-whitees esencial para el contraste en todas las combinaciones de color. -
Cambio de diseño por carga de imagen -- Sin ancho y alto explícitos, el avatar se colapsa a 0px hasta que se carga la imagen. Siempre establece dimensiones fijas en el contenedor.
-
Z-index de apilamiento de grupo de avatares -- Los avatares superpuestos pueden renderizarse en el orden incorrecto. Agrega
hover:z-10si quieres que los avatares desplazados sobresalgan por encima de sus vecinos.