Skip to content
ssntl/ui

Avatar

A circle for people, a smaller square for workspaces, with initials centered by cap height.

Installation

$ pnpm dlx shadcn@latest add @ssntl/avatar

Usage

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

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

Rules

What this component expects from you, as it ships in components/ui/avatar.md next to the code. The full file adds when to use it, keyboard, the UI foundations it applies and every shared rule.

components/ui/avatar.md
## Rules for Avatar

- **Icon area.** Circles for people, squares for workspaces. A square is drawn about 0.89Γ— the circle (48 β†’ 44) so both hold the same area and look the same size.
- Initials are centered by cap height (`text-trim`), so they sit in the optical middle.
- **Pass name.** It names the avatar for screen readers and gives the initials, counted in graphemes so emoji, accents and CJK stay whole (🦊 Fox β†’ 🦊F, ηŽ‹η§€θ‹± β†’ ηŽ‹).
- If src fails to load, the initials show instead of a broken image.
- Without name, pass `role="img"` and an `aria-label` yourself; an avatar beside a visible name can stay decorative.
- A 1px outline at 10% keeps a photo's edge on any surface.
Open the full rules file (opens in a new tab)

Code

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 }