Avatar
Radio de 6px, no circular — misma decisión que Badge frente a la convención habitual. Cae en cascada: foto si carga, iniciales si les diste fallback, un ícono genérico si no hay ninguna de las dos.
Playground
<Avatar status="online" src="/foto.jpg" alt="María Zamora" />Código

"use client";
import * as React from "react";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
export type AvatarSize = "xs" | "sm" | "md" | "lg";
export type AvatarStatus = "online" | "away" | "busy" | "offline";
// mismas alturas que Button/Input (h-7/h-8/h-10/h-12) — para que calcen
// en la misma fila si conviven (una lista de usuarios con acciones al lado)
const sizeVariants: Record<AvatarSize, string> = {
xs: "h-7 w-7 text-[10px]",
sm: "h-8 w-8 text-xs",
md: "h-10 w-10 text-sm",
lg: "h-12 w-12 text-base",
};
const iconSizeVariants: Record<AvatarSize, string> = {
xs: "h-3.5 w-3.5",
sm: "h-4 w-4",
md: "h-5 w-5",
lg: "h-6 w-6",
};
const statusSizeVariants: Record<AvatarSize, string> = {
xs: "h-2 w-2",
sm: "h-2 w-2",
md: "h-2.5 w-2.5",
lg: "h-3 w-3",
};
const statusColors: Record<AvatarStatus, string> = {
online: "bg-success",
away: "bg-warning",
busy: "bg-danger",
offline: "bg-muted-foreground",
};
function PersonIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" />
</svg>
);
}
const AvatarGroupContext = React.createContext<{ size: AvatarSize } | null>(null);
export interface AvatarProps extends React.HTMLAttributes<HTMLSpanElement> {
src?: string;
alt?: string;
/** iniciales u otro texto corto (2 caracteres, más no entra) — sin esto y sin src, cae a un ícono genérico */
fallback?: string;
size?: AvatarSize;
/** punto de estado — el único lugar donde un círculo es la forma correcta, ver nota en Colores */
status?: AvatarStatus;
}
// radio de 6px, no circular — misma decisión deliberada que Badge frente
// a la convención habitual de avatares. dentro de un AvatarGroup, el size
// del grupo manda sobre el propio (para que la pila quede pareja).
export const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
({ src, alt = "", fallback, size, status, className, ...props }, ref) => {
const groupCtx = React.useContext(AvatarGroupContext);
const effectiveSize = groupCtx?.size ?? size ?? "md";
// se guarda el src ya resuelto (no un boolean) y se compara contra el
// src actual — así, si cambia (lista con distintos usuarios
// reutilizando el mismo nodo), el fade-in vuelve a correr solo
const [loadedSrc, setLoadedSrc] = React.useState<string | undefined>(undefined);
const [erroredSrc, setErroredSrc] = React.useState<string | undefined>(undefined);
const loaded = loadedSrc === src;
const errored = erroredSrc === src;
const showImage = Boolean(src) && !errored;
const imgRef = React.useRef<HTMLImageElement>(null);
// un archivo local/cacheado puede terminar de cargar antes de que
// React alcance a enganchar onLoad — si eso pasa, el evento nunca
// llega y el fade-in queda pegado en opacity-0 para siempre. este
// chequeo cubre ese caso: si al montar la imagen ya está completa,
// dispara el mismo estado que onLoad habría disparado.
React.useLayoutEffect(() => {
const img = imgRef.current;
if (img && img.complete) {
if (img.naturalWidth > 0) setLoadedSrc(src);
else setErroredSrc(src);
}
}, [src]);
return (
// el recorte (overflow-hidden) vive en un span interno, no aquí — si
// estuviera en este nivel, el punto de estado (que necesita
// sobresalir del cuadro) se cortaría junto con la foto
<span
ref={ref}
role={!showImage && alt ? "img" : undefined}
aria-label={!showImage && alt ? alt : undefined}
className={cn("relative inline-flex shrink-0", sizeVariants[effectiveSize], groupCtx && "-ml-3 first:ml-0", className)}
{...props}
>
<span
className={cn(
"flex h-full w-full items-center justify-center overflow-hidden rounded-md bg-muted font-medium uppercase text-muted-foreground",
groupCtx && "ring-2 ring-background",
)}
>
{showImage ? (
// eslint-disable-next-line @next/next/no-img-element -- packages/ui es portable, no depende de next/image
<img
ref={imgRef}
src={src}
alt={alt}
onLoad={() => setLoadedSrc(src)}
onError={() => setErroredSrc(src)}
className={cn(
"h-full w-full object-cover transition-opacity duration-200",
EASE,
loaded ? "opacity-100" : "opacity-0",
)}
/>
) : fallback ? (
<span aria-hidden="true">{fallback}</span>
) : (
<PersonIcon className={iconSizeVariants[effectiveSize]} />
)}
</span>
{/* anillo del color de fondo — "recorta" el punto contra lo que
sea que haya detrás, misma técnica que el solape de
AvatarGroup. offset negativo para que asome fuera del cuadro
en vez de quedar comido por su propio borde */}
{status && (
<span
aria-hidden="true"
className={cn(
"absolute -bottom-0.5 -right-0.5 rounded-full ring-2 ring-background",
statusSizeVariants[effectiveSize],
statusColors[status],
)}
/>
)}
</span>
);
},
);
Avatar.displayName = "Avatar";
export interface AvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
size?: AvatarSize;
/** cuántos avatares mostrar antes de colapsar el resto en un +N */
max?: number;
}
// asume que se apoya sobre bg-background (el fondo de página) — el
// anillo que separa cada avatar del siguiente es ese mismo color
export const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
({ size = "md", max, className, children, ...props }, ref) => {
const items = React.Children.toArray(children);
const visible = typeof max === "number" ? items.slice(0, max) : items;
const overflow = typeof max === "number" ? Math.max(0, items.length - max) : 0;
return (
<AvatarGroupContext.Provider value={{ size }}>
<div ref={ref} className={cn("flex items-center", className)} {...props}>
{visible}
{overflow > 0 && <Avatar fallback={`+${overflow}`} aria-label={`${overflow} más`} />}
</div>
</AvatarGroupContext.Provider>
);
},
);
AvatarGroup.displayName = "AvatarGroup";El cuarto tiene un src roto a propósito — cae a fallback solo, sin ícono de imagen rota. Código real de packages/ui/src/avatar.tsx.
Estado
status — el único lugar de la librería donde un punto circular es la forma correcta, no una excepción a la regla de 6px sino un elemento distinto (un indicador, no un contenedor). Mismos cuatro tonos semánticos que el resto de la librería — ver Colores.
<Avatar fallback="MZ" status="online" />
<Avatar fallback="JP" status="away" />
<Avatar fallback="AL" status="busy" />
<Avatar fallback="RC" status="offline" />Agrupado
AvatarGroup superpone los avatares (mismo anillo del color de fondo que recorta el punto de estado, aquí recorta contra el avatar de atrás) y fuerza un size único — no tiene sentido una pila pareja con tamaños mezclados. Con max, el resto se colapsa solo en un +N — no cuentas tú cuántos sobran.
import { Avatar, AvatarGroup } from "./components/ui/avatar";
<AvatarGroup max={4}>
<Avatar fallback="MZ" alt="María Zamora" />
<Avatar fallback="JP" alt="Juan Pérez" />
<Avatar fallback="AL" alt="Ana López" />
<Avatar fallback="RC" alt="Rodrigo Cruz" />
<Avatar fallback="TN" alt="Tomás Núñez" />
<Avatar fallback="OS" alt="Olivia Serrano" />
</AvatarGroup>
{/* 6 avatares, max=4 → se ven 4 + un "+2" con la misma forma que cualquier Avatar */}Uso
import { Avatar } from "./components/ui/avatar";
<Avatar src={url} alt="María Zamora" fallback="MZ" />API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| Avatar | src / alt | string | — |
| fallback | string | — | |
| size | xs | sm | md | lg | md | |
| status | online | away | busy | offline | — | |
| AvatarGroup | size | xs | sm | md | lg | md |
| max | number | — |
Dentro de un AvatarGroup, el size de cada Avatar individual se ignora — manda el del grupo.