← Componentes

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

Size
Content
Status
María Zamora
<Avatar status="online" src="/foto.jpg" alt="María Zamora" />

Código

María ZamoraAna López — la foto no carga, cae a iniciales
"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

ComponentePropTipoDefault
Avatarsrc / altstring
fallbackstring
sizexs | sm | md | lgmd
statusonline | away | busy | offline
AvatarGroupsizexs | sm | md | lgmd
maxnumber

Dentro de un AvatarGroup, el size de cada Avatar individual se ignora — manda el del grupo.