{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar-group",
  "type": "registry:ui",
  "title": "Avatar Group",
  "description": "Overlapping faces with true cutouts that fan apart and open the full list.",
  "categories": [
    "display"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/avatar.json"
  ],
  "files": [
    {
      "path": "components/ui/avatar-group.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useState } from \"react\";\nimport { Avatar, type Presence } from \"@/components/ui/avatar\";\nimport { cn } from \"@/lib/cn\";\nimport { dur, ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type AvatarGroupPerson = {\n  /** Stable key. Falls back to the name. */\n  id?: string;\n  name: string;\n  src?: string;\n  status?: Presence;\n  /** A short second line: a role, an email, \"Editing\". Shown in the tooltip and the list. */\n  meta?: string;\n};\n\ntype Size = \"xs\" | \"sm\" | \"md\" | \"lg\";\nconst px: Record<Size, number> = { xs: 20, sm: 24, md: 32, lg: 40 };\n\ntype Tip = { title: string; meta?: string };\n\n// One registered custom property drives the hover spread. Registering it lets CSS interpolate it,\n// so the faces and the cutouts that follow them move together, and reverse mid-way when the\n// pointer leaves. React hoists and dedupes this, however many groups are on the page.\nconst spreadRule = \"@property --avatar-spread{syntax:'<length>';inherits:true;initial-value:0px}\";\n\nexport type AvatarGroupProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  people: AvatarGroupPerson[];\n  /** Most circles drawn, the +N chip included. One extra person is shown rather than a \"+1\". */\n  max?: number;\n  size?: Size;\n  /** What the group is: \"Viewing now\", \"Assignees\". Titles the list and names the button. */\n  label?: string;\n  /** Draws placeholder faces while the people load. */\n  loading?: boolean;\n  /** Rendered when there is nobody. Nothing by default. */\n  empty?: React.ReactNode;\n  /** Where the list popover and tooltips portal. Defaults to document.body. */\n  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n};\n\n/**\n * A stack of faces that opens the full list. The overlaps are real cutouts, so it sits on any\n * surface. On hover the stack eases apart and one tooltip glides from face to face.\n */\nexport function AvatarGroup({\n  people,\n  max = 4,\n  size = \"sm\",\n  label = \"People\",\n  loading = false,\n  empty = null,\n  container,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n  style,\n  ...rest\n}: AvatarGroupProps) {\n  const reduce = useReducedMotion();\n  const [tip] = useState(() => Tooltip.createHandle<Tip>());\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n\n  const s = px[size];\n  const overlap = Math.round(s * 0.3);\n  const gap = s >= 32 ? 2 : 1.5;\n  const cap = Math.max(2, max);\n  const fits = people.length <= cap;\n  const shown = fits ? people : people.slice(0, cap - 1);\n  const hidden = fits ? [] : people.slice(cap - 1);\n\n  if (!loading && people.length === 0) return <>{empty}</>;\n\n  // Each face after the first has a circle punched out where the one before it sits, plus a gap,\n  // so the stack reads the same on a card, a hover wash or a photo. Opaque stops only lend alpha.\n  const cut = `radial-gradient(circle at calc(${overlap - s / 2}px - var(--avatar-spread)) 50%, transparent ${s / 2 + gap}px, var(--fg) ${s / 2 + gap + 0.5}px)`;\n  const count = shown.length + (hidden.length > 0 ? 1 : 0);\n  // The spread fans out from the middle, so the stack grows evenly into both sides of its slot.\n  const item = (i: number, n = count): React.CSSProperties => ({\n    zIndex: 20 - i,\n    marginLeft: i === 0 ? 0 : -overlap,\n    translate: `calc(var(--avatar-spread) * ${i - (n - 1) / 2}) 0`,\n    maskImage: i === 0 ? undefined : cut,\n    WebkitMaskImage: i === 0 ? undefined : cut,\n  });\n\n  const names = people.map((p) => p.name);\n  const spoken =\n    names.length <= 3\n      ? `${label}: ${new Intl.ListFormat(\"en\", { type: \"conjunction\" }).format(names)}`\n      : `${label}: ${names.slice(0, 2).join(\", \")} and ${names.length - 2} others`;\n\n  const enter = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 };\n  const transition = reduce ? { duration: 0.15 } : spring.pop;\n\n  if (loading)\n    return (\n      <div aria-busy aria-label={`Loading ${label.toLowerCase()}`} role=\"img\" className={cn(\"inline-flex\", className)} style={style} {...rest}>\n        {Array.from({ length: Math.min(3, cap) }, (_, i) => (\n          <span key={i} className=\"relative flex\" style={item(i, 3)}>\n            <Avatar loading size={s} alt=\"\" />\n          </span>\n        ))}\n      </div>\n    );\n\n  return (\n    <div className={cn(\"inline-flex\", className)} style={style} {...rest}>\n      <style href=\"stealth-avatar-spread\" precedence=\"default\">\n        {spreadRule}\n      </style>\n      <Popover.Root open={open} onOpenChange={(next) => setOpen(next)}>\n        <Popover.Trigger\n          aria-label={spoken}\n          className={cn(\n            \"group/stack relative isolate inline-flex items-center rounded-full outline-none\",\n            \"transition-[--avatar-spread,scale] duration-200 ease-out-quart active:scale-[0.97] active:duration-75\",\n            \"hover:[--avatar-spread:3px] focus-visible:[--avatar-spread:3px] data-popup-open:[--avatar-spread:3px]\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            // The stack draws at 20–40px tall; on touch the target grows to 44px without moving it.\n            \"before:absolute before:-inset-y-2.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n          )}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {shown.map((p, i) => (\n              <motion.span\n                key={p.id ?? p.name}\n                layout={!reduce}\n                initial={enter}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ ...enter, transition: { duration: dur.menu * 0.7, ease: ease.out } }}\n                transition={transition}\n                className=\"relative flex\"\n                style={item(i)}\n              >\n                <Tooltip.Trigger handle={tip} payload={{ title: p.name, meta: p.meta }} delay={400} render={<span className=\"flex rounded-full\" />}>\n                  <Avatar name={p.name} src={p.src} size={s} alt=\"\" />\n                </Tooltip.Trigger>\n              </motion.span>\n            ))}\n            {hidden.length > 0 && (\n              <motion.span\n                key=\"overflow\"\n                layout={!reduce}\n                initial={enter}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ ...enter, transition: { duration: dur.menu * 0.7, ease: ease.out } }}\n                transition={transition}\n                className=\"relative flex\"\n                style={item(shown.length)}\n              >\n                <Tooltip.Trigger\n                  handle={tip}\n                  payload={{ title: hiddenNames(hidden) }}\n                  delay={400}\n                  render={\n                    <span\n                      className={cn(\n                        \"grid place-items-center rounded-full bg-fg/[0.08] font-medium text-fg-2 tabular\",\n                        \"transition-colors duration-150 group-hover/stack:bg-fg/[0.12] group-hover/stack:text-fg group-data-popup-open/stack:bg-fg/[0.12] group-data-popup-open/stack:text-fg\",\n                      )}\n                      style={{ width: s, height: s, fontSize: Math.max(9.5, Math.round(s * 0.34 * 2) / 2) }}\n                    />\n                  }\n                >\n                  <NumberFlow value={hidden.length} prefix=\"+\" className=\"leading-none\" />\n                </Tooltip.Trigger>\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </Popover.Trigger>\n\n        <Popover.Portal container={container}>\n          <Popover.Positioner side=\"bottom\" align=\"end\" sideOffset={8} collisionPadding={8} className=\"z-(--z-popover)\">\n            <Popover.Popup\n              className={cn(\n                \"flex w-64 max-w-(--available-width) flex-col rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n                \"origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n                \"data-starting-style:-translate-y-1 data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n                \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart\",\n                \"data-instant:transition-none\",\n              )}\n            >\n              <div className=\"flex h-9 shrink-0 items-center justify-between gap-3 border-b border-line px-3\">\n                <Popover.Title className=\"truncate text-[12.5px] font-medium text-fg\">{label}</Popover.Title>\n                <span className=\"font-mono text-2xs text-fg-3 tabular\">{people.length}</span>\n              </div>\n              <ul\n                aria-label={label}\n                className=\"max-h-[228px] overflow-y-auto overscroll-contain p-1 [mask-image:linear-gradient(to_bottom,transparent,var(--fg)_6px,var(--fg)_calc(100%-6px),transparent)]\"\n              >\n                {people.map((p) => (\n                  <li key={p.id ?? p.name} className=\"flex h-10 items-center gap-2.5 rounded-lg px-2\">\n                    <Avatar name={p.name} src={p.src} size=\"sm\" status={p.status} alt=\"\" />\n                    <span className=\"min-w-0 flex-1 truncate text-[13px] text-fg\">{p.name}</span>\n                    {p.meta && <span className=\"max-w-[45%] shrink-0 truncate text-[12px] text-fg-3\">{p.meta}</span>}\n                  </li>\n                ))}\n              </ul>\n            </Popover.Popup>\n          </Popover.Positioner>\n        </Popover.Portal>\n      </Popover.Root>\n\n      {/* One tooltip for the whole stack. It glides between faces instead of closing and reopening. */}\n      <Tooltip.Root handle={tip} disabled={open}>\n        {({ payload }) => (\n          <Tooltip.Portal container={container}>\n            <Tooltip.Positioner\n              side=\"top\"\n              sideOffset={8}\n              collisionPadding={8}\n              className=\"z-(--z-tooltip) h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-200 ease-out-quart data-instant:transition-none\"\n            >\n              <Tooltip.Popup\n                className={cn(\n                  \"h-(--popup-height,auto) w-(--popup-width,auto) overflow-clip rounded-lg border border-line-2 bg-raised text-[12px] leading-4 text-fg shadow-pop outline-none\",\n                  \"origin-(--transform-origin) transition-[opacity,scale,width,height] [transition-duration:150ms,150ms,200ms,200ms] ease-out-expo\",\n                  \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:[transition-duration:100ms]\",\n                  \"data-instant:transition-none\",\n                )}\n              >\n                <Tooltip.Viewport\n                  className={cn(\n                    \"relative h-full w-full\",\n                    \"[&>*]:flex [&>*]:w-max [&>*]:max-w-[16rem] [&>*]:items-baseline [&>*]:gap-1.5 [&>*]:px-2 [&>*]:py-[5px]\",\n                    \"[&>[data-previous]]:absolute [&>[data-previous]]:left-0 [&>[data-previous]]:top-0\",\n                    \"[&>*]:transition-[opacity,filter] [&>*]:duration-150\",\n                    \"[&>[data-current][data-starting-style]]:opacity-0 [&>[data-current][data-starting-style]]:blur-[2px]\",\n                    \"[&>[data-previous][data-ending-style]]:opacity-0 [&>[data-previous]]:duration-100\",\n                  )}\n                >\n                  {payload && (\n                    <>\n                      <span className=\"truncate\">{payload.title}</span>\n                      {payload.meta && <span className=\"shrink-0 text-fg-3\">{payload.meta}</span>}\n                    </>\n                  )}\n                </Tooltip.Viewport>\n              </Tooltip.Popup>\n            </Tooltip.Positioner>\n          </Tooltip.Portal>\n        )}\n      </Tooltip.Root>\n    </div>\n  );\n}\n\nfunction hiddenNames(hidden: AvatarGroupPerson[]) {\n  const first = hidden.slice(0, 2).map((p) => p.name.split(/\\s+/)[0]);\n  const rest = hidden.length - first.length;\n  return rest > 0 ? `${first.join(\", \")} and ${rest} more` : first.join(\" and \");\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/avatar-group"
}
