Saltar al contenido
ssntl/ui

Avatar

Un círculo para personas y un cuadrado más pequeño para espacios de trabajo, con iniciales centradas por altura de mayúsculas.

Instalación

$ pnpm dlx shadcn@latest add @ssntl/avatar

Uso

tsx
import { Avatar } from "@/components/ui/avatar"

<Avatar name="Olivia Sosa" />
<Avatar shape="square" name="Studio Norte" />
<Avatar name="Olivia Sosa" src="/olivia.webp" />

Reglas

Lo que este componente espera de ti, tal como se instala en components/ui/avatar.md junto al código. El archivo completo agrega cuándo usarlo, teclado, los fundamentos de UI que aplica y cada regla compartida (en inglés).

components/ui/avatar.md
## Reglas de Avatar

- Área de íconos: círculos para personas, cuadrados para espacios de trabajo. El cuadrado se dibuja cerca de 0.89× el círculo (48 → 44), así ambos tienen la misma área y se ven del mismo tamaño.
- Las iniciales se centran por la altura de mayúsculas (`text-trim`), así quedan en el centro óptico.
- **Pasa name.** Nombra el avatar para lectores de pantalla y da las iniciales, contadas por grafemas para que emoji, acentos y CJK queden enteros (🦊 Fox → 🦊F, 王秀英 → 王).
- Si src no carga, se muestran las iniciales en lugar de una imagen rota.
- Sin name, pasa tú `role="img"` y un `aria-label`; un avatar junto a un nombre visible puede quedar decorativo.
- Un contorno de 1px al 10% mantiene el borde de una foto sobre cualquier superficie.
Abrir el archivo de reglas completo (se abre en otra pestaña)

Código

components/ui/avatar.tsx
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

// A person (circle) or a workspace (square). Icon area: a square of the same
// width as a circle holds 27% more area and looks bigger, so squares are
// drawn about 0.89× the circle (equal area): 48 → 44, 36 → 32, 56 → 50.
// Initials are centered by their cap height (text-trim), not the line box,
// so "OS" sits in the optical middle. A broken image falls back to the
// initials, so a 404 never shows as a broken-image icon.
const avatarVariants = cva(
  "inline-grid shrink-0 place-items-center overflow-hidden bg-secondary font-medium text-foreground uppercase outline -outline-offset-1 outline-black/10 dark:outline-white/10 [&_img]:size-full [&_img]:object-cover",
  {
    variants: {
      shape: { circle: "rounded-full", square: "rounded-xs" },
      size: { sm: "text-caption", md: "text-label", lg: "text-body" },
    },
    compoundVariants: [
      { shape: "circle", size: "sm", className: "size-9" },
      { shape: "circle", size: "md", className: "size-12" },
      { shape: "circle", size: "lg", className: "size-14" },
      { shape: "square", size: "sm", className: "size-8" },
      { shape: "square", size: "md", className: "size-11" },
      { shape: "square", size: "lg", className: "size-12.5" },
    ],
    defaultVariants: { shape: "circle", size: "md" },
  }
)

/**
 * Initials from a full name, counted in graphemes so emoji and accents stay
 * whole: "Ana López" → "AL", "🦊 Fox" → "🦊F", "王秀英" → "王", "dana" → "D".
 */
function getInitials(name: string, locale?: string) {
  const words = name.trim().split(/\s+/).filter(Boolean)
  if (words.length === 0) return ""
  const graphemes = new Intl.Segmenter(locale, { granularity: "grapheme" })
  const first = (word: string) =>
    graphemes.segment(word)[Symbol.iterator]().next().value?.segment ?? ""
  const picks = words.length === 1 ? [words[0]!] : [words[0]!, words.at(-1)!]
  return picks.map(first).join("").toLocaleUpperCase(locale)
}

function Avatar({
  shape,
  size,
  name,
  src,
  className,
  children,
  ...props
}: React.ComponentProps<"span"> &
  VariantProps<typeof avatarVariants> & {
    /** The person's or workspace's name: names the avatar and gives initials. */
    name?: string
    /** A photo or logo; if it fails to load, the initials show instead. */
    src?: string
  }) {
  const [failed, setFailed] = React.useState<string | null>(null)
  const fallback = children ?? (name ? getInitials(name) : null)
  const showImage = src && failed !== src

  return (
    <span
      data-slot="avatar"
      role={name ? "img" : undefined}
      aria-label={name}
      className={cn(avatarVariants({ shape, size, className }))}
      {...props}
    >
      {showImage ? (
        // eslint-disable-next-line @next/next/no-img-element -- any image source
        <img src={src} alt="" onError={() => setFailed(src)} />
      ) : typeof fallback === "string" ? (
        <span aria-hidden={name ? true : undefined} className="text-trim">
          {fallback}
        </span>
      ) : (
        fallback
      )}
    </span>
  )
}

export { Avatar, avatarVariants, getInitials }