{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar-circles",
  "title": "Avatar Circles",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Overlapping avatars that spread on hover, lift individually with a name and role tooltip, show presence, fall back to initials and end in a +N count that can be a button.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/avatar-circles.tsx",
      "content": "// Ballmac UI: Avatar Circles. https://ui.ballmac.com/components/avatar-circles\n\"use client\"\n\nimport * as React from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype AvatarPerson = {\n  /** Full name. Used for the tooltip and the accessible name. */\n  name: string\n  /** Picture URL. Initials are shown when missing or when it fails to load. */\n  src?: string\n  /** Second line in the tooltip, such as a role. */\n  role?: string\n  /** Presence dot, shown with a word for assistive tech. */\n  status?: \"online\" | \"busy\" | \"away\"\n  /** Makes the avatar a link. */\n  href?: string\n}\n\nconst TONES = [\n  \"bg-[color-mix(in_oklab,var(--chart-1)_62%,var(--background))]\",\n  \"bg-[color-mix(in_oklab,var(--chart-2)_62%,var(--background))]\",\n  \"bg-[color-mix(in_oklab,var(--chart-3)_62%,var(--background))]\",\n  \"bg-[color-mix(in_oklab,var(--chart-4)_62%,var(--background))]\",\n  \"bg-[color-mix(in_oklab,var(--chart-5)_62%,var(--background))]\",\n]\nconst SIZES = {\n  sm: { box: \"size-8 text-[11px]\", overlap: \"-ms-2.5\", spread: \"group-hover/circles:-ms-1 group-focus-within/circles:-ms-1\", dot: \"size-2\" },\n  default: { box: \"size-10 text-xs\", overlap: \"-ms-3\", spread: \"group-hover/circles:-ms-1.5 group-focus-within/circles:-ms-1.5\", dot: \"size-2.5\" },\n  lg: { box: \"size-14 text-sm\", overlap: \"-ms-4\", spread: \"group-hover/circles:-ms-2 group-focus-within/circles:-ms-2\", dot: \"size-3\" },\n}\nconst STATUS_DOT = { online: \"bg-chart-2\", busy: \"bg-destructive\", away: \"bg-chart-3\" }\nconst STATUS_WORD = { online: \"online\", busy: \"busy\", away: \"away\" }\n\nfunction hue(text: string) {\n  let h = 0\n  for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0\n  return h % TONES.length\n}\n\nfunction initials(name: string) {\n  const words = name.trim().split(/\\s+/)\n  return ((words[0]?.[0] ?? \"\") + (words.length > 1 ? (words[words.length - 1]?.[0] ?? \"\") : \"\")).toUpperCase()\n}\n\nfunction Face({ person, size }: { person: AvatarPerson; size: keyof typeof SIZES }) {\n  const [failed, setFailed] = React.useState(false)\n  const s = SIZES[size]\n  return (\n    <span\n      className={cn(\n        \"relative flex shrink-0 items-center justify-center overflow-hidden rounded-full ring-2 ring-background font-semibold text-foreground\",\n        s.box,\n        (!person.src || failed) && TONES[hue(person.name)]\n      )}\n    >\n      {person.src && !failed ? (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={person.src} alt=\"\" className=\"size-full object-cover\" onError={() => setFailed(true)} />\n      ) : (\n        initials(person.name)\n      )}\n    </span>\n  )\n}\n\ntype AvatarCirclesProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The people to show, first on the left. */\n  people: AvatarPerson[]\n  /** How many faces show before the \"+N\" circle. */\n  max?: number\n  /** Total number of people, when more than `people.length` (for example 1,200 members but 8 loaded). */\n  total?: number\n  /** Face size. */\n  size?: keyof typeof SIZES\n  /** Makes the \"+N\" circle a button. */\n  onOverflowClick?: () => void\n  /** Accessible name of the whole group. */\n  label?: string\n}\n\nfunction AvatarCircles({ people, max = 5, total, size = \"default\", onOverflowClick, label, className, ...props }: AvatarCirclesProps) {\n  const msg = useMessages()\n  const reduce = useReducedMotion()\n  const shown = people.slice(0, max)\n  const extra = Math.max((total ?? people.length) - shown.length, 0)\n  const s = SIZES[size]\n  const summary = label ?? `${total ?? people.length} ${(total ?? people.length) === 1 ? \"person\" : \"people\"}`\n\n  return (\n    <div data-slot=\"avatar-circles\" role=\"group\" aria-label={summary} className={cn(\"group/circles flex items-center\", className)} {...props}>\n      {shown.map((person, i) => {\n        const name = person.status ? `${person.name}, ${STATUS_WORD[person.status]}` : person.name\n        const inner = (\n          <>\n            <Face person={person} size={size} />\n            {person.status && (\n              <span aria-hidden=\"true\" className={cn(\"absolute end-0 bottom-0 z-10 rounded-full ring-2 ring-background\", s.dot, STATUS_DOT[person.status])} />\n            )}\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute bottom-full left-1/2 z-30 mb-2 -translate-x-1/2 rounded-lg border bg-popover px-2.5 py-1.5 text-center text-xs whitespace-nowrap text-popover-foreground opacity-0 shadow-md transition-opacity duration-150 group-hover/face:opacity-100 group-focus-visible/face:opacity-100 motion-reduce:transition-none\"\n            >\n              <span className=\"block font-medium\">{person.name}</span>\n              {person.role && <span className=\"block text-muted-foreground\">{person.role}</span>}\n            </span>\n          </>\n        )\n        const common = cn(\n          \"group/face relative inline-flex rounded-full outline-none transition-[margin] duration-200 focus-visible:z-20 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\",\n          i > 0 && s.overlap,\n          i > 0 && s.spread\n        )\n        return (\n          <motion.span\n            key={`${person.name}-${i}`}\n            className=\"relative inline-flex\"\n            style={{ zIndex: shown.length - i }}\n            whileHover={reduce ? undefined : { y: -4, scale: 1.1, zIndex: 40 }}\n            whileFocus={reduce ? undefined : { y: -4, scale: 1.1, zIndex: 40 }}\n            transition={{ type: \"spring\", stiffness: 500, damping: 28 }}\n          >\n            {person.href ? (\n              <a href={person.href} aria-label={name} className={common}>\n                {inner}\n              </a>\n            ) : (\n              <button type=\"button\" aria-label={name} className={common}>\n                {inner}\n              </button>\n            )}\n          </motion.span>\n        )\n      })}\n      {extra > 0 &&\n        (onOverflowClick ? (\n          <button\n            type=\"button\"\n            onClick={onOverflowClick}\n            aria-label={msg(\"avatar-circles.andMore\", \"and {extra} more\", { extra })}\n            className={cn(\"relative z-0 flex shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-foreground ring-2 ring-background outline-none transition-[margin,background-color] duration-200 hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\", s.box, s.overlap, s.spread)}\n          >\n            +{extra}\n          </button>\n        ) : (\n          <span\n            role=\"img\"\n            aria-label={msg(\"avatar-circles.andMore\", \"and {extra} more\", { extra })}\n            className={cn(\"relative z-0 flex shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-foreground ring-2 ring-background transition-[margin] duration-200\", s.box, s.overlap, s.spread)}\n          >\n            +{extra}\n          </span>\n        ))}\n    </div>\n  )\n}\n\nexport { AvatarCircles, type AvatarCirclesProps, type AvatarPerson }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/avatar-circles.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "avatar",
      "people",
      "stack",
      "presence",
      "team"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<AvatarCircles people={[{ name, src, role, status, href }]} max total size onOverflowClick />. Each face is a link or button named with its person (and status). total adds the +N when only some are loaded.",
      "whenToUse": [
        "Showing who is in a project, document or room",
        "Social proof next to a call to action"
      ],
      "whenNotToUse": [
        "A plain compact row (avatar-stack)",
        "Large member tables (data-table)"
      ],
      "composesWith": [
        "avatar-stack",
        "avatar",
        "tooltip"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Each person is focusable and shows the tooltip on focus"
        },
        {
          "keys": "Screen readers",
          "action": "Named 'Ada Lovelace, online'; the group announces the people count"
        },
        {
          "keys": "Reduced motion",
          "action": "No lift; the spread still happens with a plain transition"
        }
      ],
      "customization": [
        "max and total",
        "size: sm | default | lg",
        "status",
        "href",
        "onOverflowClick"
      ]
    },
    "examples": [
      "avatar-circles-demo",
      "avatar-circles-sizes"
    ],
    "url": "https://ui.ballmac.com/components/avatar-circles"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}