{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"avatar","type":"registry:component","title":"Avatar","description":"A face, or initials until there is one: the photo rises out of a blur once it has loaded, and a stack of avatars fans open when you point at it.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/avatar.tsx","type":"registry:component","content":"// Hairline UI · avatar · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * AVATAR: a face, or initials until there is one\n *\n *   load     initials show at once; the photo comes up out of a\n *            blur over them once it has decoded, never a broken\n *            image or a flash of empty circle\n *   status   a dot on the edge (online, away, busy, offline), cut\n *            out of the photo by a ring of the background\n *   group    faces overlap; pointing at the stack fans it open\n *            so each face shows whole, and the \"+N\" rolls\n * ───────────────────────────────────────────────────────── */\n\nexport type AvatarStatus = \"online\" | \"away\" | \"busy\" | \"offline\";\nexport type AvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\";\n\nexport interface AvatarProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  name: string;\n  src?: string;\n  size?: AvatarSize;\n  status?: AvatarStatus;\n  /** Square-ish (for teams and workspaces) instead of round. */\n  shape?: \"circle\" | \"square\";\n}\n\nconst PX: Record<AvatarSize, number> = { xs: 20, sm: 28, md: 36, lg: 48 };\nconst STATUS: Record<AvatarStatus, string> = { online: \"bg-emerald-500\", away: \"bg-amber-500\", busy: \"bg-red-500\", offline: \"bg-muted-foreground/50\" };\n\nexport function initials(name: string) {\n  const parts = name.trim().split(/\\s+/).filter(Boolean);\n  return ((parts[0]?.[0] ?? \"\") + (parts.length > 1 ? parts[parts.length - 1][0] : \"\")).toUpperCase();\n}\n\nexport function Avatar({ name, src, size = \"md\", status, shape = \"circle\", className = \"\", style, ...props }: AvatarProps) {\n  const reduced = useInteractionReducedMotion();\n  const [loaded, setLoaded] = React.useState<string | null>(null);\n  const ready = !!src && loaded === src;\n  const px = PX[size];\n  const radius = shape === \"circle\" ? \"rounded-full\" : px >= 36 ? \"rounded-[10px]\" : \"rounded-[7px]\";\n\n  return (\n    <span\n      role=\"img\"\n      aria-label={status ? `${name}, ${status}` : name}\n      className={`relative inline-flex shrink-0 select-none ${className}`}\n      style={{ width: px, height: px, ...style }}\n      {...props}\n    >\n      <span className={`absolute inset-0 flex items-center justify-center overflow-hidden bg-muted font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] ${radius}`} style={{ fontSize: Math.round(px * 0.38) }}>\n        {initials(name)}\n        {src && (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img\n            src={src}\n            alt=\"\"\n            decoding=\"async\"\n            onLoad={() => setLoaded(src)}\n            // A cached photo can finish before React listens; take it from the element.\n            ref={(el) => {\n              if (el?.complete && el.naturalWidth && loaded !== src) setLoaded(src);\n            }}\n            className=\"absolute inset-0 size-full object-cover\"\n            style={{\n              opacity: ready ? 1 : 0,\n              filter: ready || reduced ? \"none\" : \"blur(6px)\",\n              scale: ready || reduced ? \"1\" : \"1.08\",\n              transition: reduced ? \"opacity 150ms\" : `opacity 360ms ${EASE_OUT}, filter 420ms ${EASE_OUT}, scale 520ms ${EASE_OUT}`,\n            }}\n          />\n        )}\n      </span>\n      {status && (\n        <span\n          aria-hidden=\"true\"\n          className={`absolute bottom-0 right-0 rounded-full shadow-[0_0_0_2px_var(--background)] transition-colors duration-300 ${STATUS[status]}`}\n          style={{ width: Math.max(6, Math.round(px * 0.26)), height: Math.max(6, Math.round(px * 0.26)) }}\n        />\n      )}\n    </span>\n  );\n}\n\nexport interface AvatarGroupProps {\n  people: { name: string; src?: string }[];\n  /** How many faces before \"+N\". */\n  max?: number;\n  size?: AvatarSize;\n  className?: string;\n}\n\nexport function AvatarGroup({ people, max = 4, size = \"sm\", className = \"\" }: AvatarGroupProps) {\n  const reduced = useInteractionReducedMotion();\n  const [open, setOpen] = React.useState(false);\n  const shown = people.slice(0, max);\n  const rest = people.length - shown.length;\n  const px = PX[size];\n  const overlap = Math.round(px * 0.3);\n  const gap = open && !reduced ? 4 : -overlap;\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={`${people.length} people`}\n      className={`flex items-center ${className}`}\n      onPointerEnter={(e) => e.pointerType === \"mouse\" && setOpen(true)}\n      onPointerLeave={() => setOpen(false)}\n    >\n      {shown.map((p, i) => (\n        <span\n          key={p.name + i}\n          className=\"relative rounded-full shadow-[0_0_0_2px_var(--background)]\"\n          style={{ marginLeft: i ? gap : 0, zIndex: shown.length - i, transition: reduced ? \"none\" : `margin-left 380ms ${EASE_OUT} ${i * 20}ms` }}\n        >\n          <Avatar name={p.name} src={p.src} size={size} />\n        </span>\n      ))}\n      {rest > 0 && (\n        <span\n          className=\"relative flex items-center justify-center rounded-full bg-muted font-medium tabular-nums text-muted-foreground shadow-[inset_0_0_0_1px_var(--border),0_0_0_2px_var(--background)]\"\n          style={{ width: px, height: px, fontSize: Math.round(px * 0.34), marginLeft: gap, transition: reduced ? \"none\" : `margin-left 380ms ${EASE_OUT} ${shown.length * 20}ms` }}\n          aria-label={`and ${rest} more`}\n        >\n          +<NumberRoll value={rest} />\n        </span>\n      )}\n    </div>\n  );\n}\n\nexport default Avatar;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/avatar. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}