{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"avatar-group","type":"registry:ui","title":"Avatar group","description":"A compact stack of avatars with an overflow count and presence.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/avatar.tsx","type":"registry:ui","target":"components/ui/avatar.tsx","content":"\"use client\";\n\nimport { createContext, useContext, useState, type ComponentProps } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./avatar.css\";\n\nexport type AvatarProps = Omit<ComponentProps<\"span\">, \"children\"> & {\n  name: string;\n  src?: string;\n  alt?: string;\n  /** \"square\" for a company, workspace or integration: a coloured mark instead of a person's orb or photo. */\n  shape?: \"circle\" | \"square\";\n  /** Shown as a shape at the corner: filled, half, dash or ring. */\n  presence?: \"online\" | \"away\" | \"busy\" | \"offline\";\n};\n\n/** Resolves a person's photo from their name when an Avatar has no `src`: your user directory, or sample faces in a demo.\n    Return undefined for anyone without one and they keep their orb. */\nexport const AvatarPhotos = createContext<((name: string) => string | undefined) | null>(null);\n\n/** The same name always gets the same orb (six glass orbs; the first follows the accent). */\nexport function avatarTone(name: string) {\n  let hash = 7;\n  for (const char of name.trim().toLowerCase()) hash = (hash * 31 + char.codePointAt(0)!) >>> 0;\n  return hash % 6;\n}\n\nfunction AvatarContent({ src, initials }: { src?: string; initials: string }) {\n  const [failed, setFailed] = useState(false);\n  return (\n    <>\n      <span aria-hidden=\"true\">{initials}</span>\n      {src && !failed && (\n        <img\n          src={src}\n          alt=\"\"\n          aria-hidden=\"true\"\n          width={32}\n          height={32}\n          decoding=\"async\"\n          className=\"absolute inset-0 size-full object-cover\"\n          ref={(image) => {\n            // A cached or pre-hydration failure may precede the error listener.\n            if (image?.complete && image.naturalWidth === 0) setFailed(true);\n          }}\n          onError={() => setFailed(true)}\n        />\n      )}\n    </>\n  );\n}\n\nexport function Avatar({\n  name,\n  src,\n  alt = name,\n  shape = \"circle\",\n  presence,\n  className,\n  ...props\n}: AvatarProps) {\n  const resolve = useContext(AvatarPhotos);\n  const photo = src ?? (shape === \"circle\" ? resolve?.(name) : undefined);\n  const words = name.trim().normalize(\"NFC\").split(/\\s+/u);\n  const initials =\n    (Array.from(words[0] ?? \"\")[0] ?? \"?\") +\n    (words.length > 1 ? (Array.from(words[words.length - 1]!)[0] ?? \"\") : \"\");\n\n  return (\n    <span\n      data-slot=\"avatar\"\n      data-tone={avatarTone(name)}\n      data-shape={shape === \"square\" ? \"square\" : undefined}\n      className={cn(\n        \"relative inline-flex size-8 shrink-0 items-center justify-center text-xs font-semibold\",\n        shape === \"square\" ? \"rounded-[8px]\" : \"rounded-full\",\n        className,\n      )}\n      {...props}\n      role={alt ? \"img\" : undefined}\n      aria-label={alt || undefined}\n      aria-hidden={!alt || undefined}\n    >\n      <AvatarContent\n        key={photo ?? \"\"}\n        src={photo}\n        initials={initials.toUpperCase()}\n      />\n      {presence && <span data-slot=\"avatar-presence\" data-presence={presence} aria-hidden=\"true\" />}\n    </span>\n  );\n}\n"},{"path":"ui/avatar-group.tsx","type":"registry:ui","target":"components/ui/avatar-group.tsx","content":"import { Avatar } from \"./avatar\";\nimport { cn } from \"./lib/cn\";\nimport \"./avatar-group.css\";\n\nexport type AvatarGroupMember = {\n  name: string;\n  src?: string;\n  status?: \"online\" | \"offline\";\n};\n\nexport function AvatarGroup({\n  members,\n  max = 4,\n  label = \"Team members\",\n  className,\n}: {\n  members: readonly AvatarGroupMember[];\n  max?: number;\n  label?: string;\n  className?: string;\n}) {\n  const shown = members.slice(0, Math.max(1, max));\n  const extra = Math.max(0, members.length - shown.length);\n  return (\n    <div role=\"group\" aria-label={label} data-slot=\"avatar-group\" className={cn(\"inline-flex items-center\", className)}>\n      {shown.map((member) => (\n        <span key={member.name} data-slot=\"avatar-group-item\" className=\"relative\">\n          <Avatar name={member.name} src={member.src} className=\"ring-2 ring-background\" />\n          {member.status === \"online\" && (\n            <span aria-hidden=\"true\" data-slot=\"avatar-status\" className=\"absolute right-0 bottom-0 size-2 rounded-full bg-success ring-2 ring-background\" />\n          )}\n        </span>\n      ))}\n      {extra > 0 && (\n        <span role=\"img\" aria-label={`${extra} more`} data-slot=\"avatar-overflow\" className=\"relative z-10 -ms-2 inline-flex size-8 items-center justify-center rounded-surface bg-muted text-xs font-medium ring-2 ring-background\">\n          +{extra}\n        </span>\n      )}\n    </div>\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/avatar.css","type":"registry:file","target":"components/ui/avatar.css","content":"/* avatar.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Avatars: a light glass orb, two soft hues with a specular highlight and a deeper lower edge. Six tones, assigned per\n     person, so a row of people reads as a calm spectrum; dark initials read on every tone at 9:1 or better. */\n  [data-slot=\"avatar\"] {\n    --av-a: #9ec5ff;\n    --av-b: #c4b2ff;\n    color: #161616;\n    background:\n      radial-gradient(circle at 30% 24%, rgb(255 255 255 / 80%), rgb(255 255 255 / 0%) 36%),\n      radial-gradient(circle at 76% 84%, color-mix(in oklab, var(--av-b) 78%, #3b2a6b), transparent 58%),\n      linear-gradient(140deg, var(--av-a) 12%, var(--av-b) 88%);\n    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 34%), inset 0 -4px 8px rgb(0 0 0 / 8%);\n  }\n  [data-slot=\"avatar\"] > span:first-child {\n    position: relative;\n    text-shadow: 0 1px 0 rgb(255 255 255 / 35%);\n  }\n  [data-slot=\"avatar\"] img {\n    border-radius: inherit;\n  }\n  [data-slot=\"avatar\"][data-tone=\"1\"] { --av-a: #a3f0c8; --av-b: #7fd3e6; }\n  [data-slot=\"avatar\"][data-tone=\"2\"] { --av-a: #c9adff; --av-b: #ffb2d8; }\n  [data-slot=\"avatar\"][data-tone=\"3\"] { --av-a: #ffd6a0; --av-b: #ffab86; }\n  [data-slot=\"avatar\"][data-tone=\"4\"] { --av-a: #ffb4a6; --av-b: #ff9ec6; }\n  [data-slot=\"avatar\"][data-tone=\"5\"] { --av-a: #b8c6ff; --av-b: #9fe1f0; }\n  /* Company marks (shape=\"square\"): a solid hue from the name with white initials, 4.7:1 or better on every hue. */\n  [data-slot=\"avatar\"][data-shape=\"square\"] {\n    --mark-h: 255;\n    color: #ffffff;\n    background: oklch(0.52 0.15 var(--mark-h));\n    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 12%);\n  }\n  [data-slot=\"avatar\"][data-shape=\"square\"] > span:first-child { text-shadow: none; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"1\"] { --mark-h: 295; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"2\"] { --mark-h: 2; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"3\"] { --mark-h: 50; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"4\"] { --mark-h: 150; }\n  [data-slot=\"avatar\"][data-shape=\"square\"][data-tone=\"5\"] { --mark-h: 195; }\n  [data-slot=\"avatar-presence\"] {\n    position: absolute;\n    right: -3px;\n    bottom: -3px;\n    width: 10px;\n    height: 10px;\n    border-radius: 50%;\n    box-shadow: 0 0 0 2px var(--background);\n  }\n  [data-slot=\"avatar-presence\"][data-presence=\"online\"] { background: var(--success); }\n  [data-slot=\"avatar-presence\"][data-presence=\"away\"] { background: linear-gradient(90deg, var(--warning) 50%, var(--background) 50%); box-shadow: 0 0 0 2px var(--background), inset 0 0 0 1.5px var(--warning); }\n  [data-slot=\"avatar-presence\"][data-presence=\"busy\"] { background: radial-gradient(var(--background) 0 0) center / 5px 1.5px no-repeat, var(--danger); }\n  [data-slot=\"avatar-presence\"][data-presence=\"offline\"] { background: var(--background); box-shadow: 0 0 0 2px var(--background), inset 0 0 0 1.5px var(--muted-foreground); }\n}\n"},{"path":"ui/avatar-group.css","type":"registry:file","target":"components/ui/avatar-group.css","content":"/* avatar-group.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"avatar-group-item\"] {\n    margin-inline-start: -0.5rem;\n    transition: margin-inline-start var(--duration-fast) var(--ease-out);\n  }\n  [data-slot=\"avatar-group-item\"]:first-child {\n    margin-inline-start: 0;\n  }\n  @media (hover: hover) and (pointer: fine) {\n    [data-slot=\"avatar-group\"]:hover [data-slot=\"avatar-group-item\"] {\n      margin-inline-start: 0.25rem;\n    }\n    [data-slot=\"avatar-group\"]:hover [data-slot=\"avatar-group-item\"]:first-child {\n      margin-inline-start: 0;\n    }\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"avatar-group-item\"] {\n      transition: margin-inline-start 120ms var(--ease-out);\n    }\n  }\n}\n"}],"categories":["navigation"],"meta":{"tier":"free","license":"MIT","family":"identity-navigation","docs":"/docs/components/avatar-group"}}