Bloom Logo

Avatar Group

Stack multiple avatars together with smooth cubic-bezier hover expansion, orientation support (horizontal or vertical), overlap density control, count truncation, and clean dark/light neutral theme styling.

importimport { AvatarGroup } from "@/components/ui/avatarGroup/avatarGroup";
$npx @bloomui-react/cli add avatarGroup
avatarGroup.tsx
"use client";

import * as React from "react";
import {
  Avatar,
  AvatarContext,
  AvatarFallback,
  type AvatarProps,
} from "@/components/ui/avatar/avatar";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip/tooltip";
import { cn } from "@/lib/utils";

export type AvatarGroupOrientation = "horizontal" | "vertical";
export type AvatarGroupOverlap = "sm" | "md" | "lg";

export interface AvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
  children: React.ReactNode;
  max?: number;
  total?: number;
  orientation?: AvatarGroupOrientation;
  overlap?: AvatarGroupOverlap;
  size?: AvatarProps["size"];
  color?: AvatarProps["color"];
  radius?: AvatarProps["radius"];
  isBordered?: boolean;
  isGrid?: boolean;
  isDisabled?: boolean;
  isPressable?: boolean;
  showTooltip?: boolean;
  renderCount?: (count: number) => React.ReactNode;
}

const overlapHorizontal: Record<AvatarGroupOverlap, string> = {
  sm: "-space-x-1.5 hover:-space-x-1",
  md: "-space-x-3 hover:-space-x-1.5",
  lg: "-space-x-4 hover:-space-x-2",
};

const overlapVertical: Record<AvatarGroupOverlap, string> = {
  sm: "-space-y-1.5 hover:-space-y-1",
  md: "-space-y-3 hover:-space-y-1.5",
  lg: "-space-y-4 hover:-space-y-2",
};

const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
  (
    {
      children,
      max,
      total,
      orientation = "horizontal",
      overlap = "md",
      size = "md",
      color = "default",
      radius = "full",
      isBordered = true,
      isGrid = false,
      isDisabled = false,
      isPressable = false,
      showTooltip = false,
      renderCount,
      className,
      ...props
    },
    ref,
  ) => {
    const childrenArray = React.Children.toArray(children);
    const countTotal = total ?? childrenArray.length;
    const hasMax =
      typeof max === "number" && max > 0 && max < childrenArray.length;
    const visibleAvatars = hasMax ? childrenArray.slice(0, max) : childrenArray;
    const excessCount = hasMax
      ? countTotal - max
      : countTotal > childrenArray.length
        ? countTotal - childrenArray.length
        : 0;

    const isVertical = orientation === "vertical";

    const groupContent = (
      <AvatarContext.Provider value={{ color, isInGroup: true }}>
        <div
          ref={ref}
          role="group"
          aria-label="Avatar group"
          className={cn(
            "inline-flex transition-all duration-300 ease-out",
            isGrid
              ? "flex-wrap gap-2"
              : isVertical
                ? cn("flex-col items-start", overlapVertical[overlap])
                : cn("items-center", overlapHorizontal[overlap]),
            isDisabled && "opacity-50 grayscale pointer-events-none",
            className,
          )}
          {...props}
        >
          {visibleAvatars.map((child, index) => {
            if (!React.isValidElement<AvatarProps>(child)) return child;

            const clonedAvatar = React.cloneElement(child, {
              size: child.props.size || size,
              color: child.props.color || color,
              radius: child.props.radius || radius,
              isBordered:
                child.props.isBordered !== undefined
                  ? child.props.isBordered
                  : isBordered,
              isDisabled:
                child.props.isDisabled !== undefined
                  ? child.props.isDisabled
                  : isDisabled,
              isPressable:
                child.props.isPressable !== undefined
                  ? child.props.isPressable
                  : isPressable,
              className: cn(
                "ring-2 ring-white dark:ring-zinc-900 transition-all duration-300 ease-out",
                child.props.className,
              ),
            });

            const avatarItem = (
              <div
                className={cn(
                  "relative transition-all duration-300 ease-out hover:z-30 hover:scale-105",
                  isVertical ? "hover:translate-x-1" : "hover:-translate-y-1",
                )}
                style={{ zIndex: visibleAvatars.length - index }}
              >
                {clonedAvatar}
              </div>
            );

            if (showTooltip) {
              const label = child.props.title || "User";
              return (
                <Tooltip key={index}>
                  <TooltipTrigger asChild>{avatarItem}</TooltipTrigger>
                  <TooltipContent>{label}</TooltipContent>
                </Tooltip>
              );
            }

            return <React.Fragment key={index}>{avatarItem}</React.Fragment>;
          })}

          {excessCount > 0 && (
            <div
              className={cn(
                "relative transition-all duration-300 ease-out hover:z-30 hover:scale-105",
                isVertical ? "hover:translate-x-1" : "hover:-translate-y-1",
              )}
              style={{ zIndex: 0 }}
            >
              <Avatar
                size={size}
                color={color}
                radius={radius}
                isBordered={isBordered}
                className="ring-2 ring-white dark:ring-zinc-900"
              >
                {renderCount ? (
                  renderCount(excessCount)
                ) : (
                  <AvatarFallback className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 font-semibold text-xs select-none">
                    +{excessCount}
                  </AvatarFallback>
                )}
              </Avatar>
            </div>
          )}
        </div>
      </AvatarContext.Provider>
    );

    if (showTooltip) {
      return <TooltipProvider>{groupContent}</TooltipProvider>;
    }

    return groupContent;
  },
);

AvatarGroup.displayName = "AvatarGroup";

export { AvatarGroup };

Default

A standard avatar group component stacking multiple user avatars with smooth hover expansion transitions.

SJ
ED
AR
MK

Overlap Density

Adjust spacing overlap tightness using the overlap prop (sm, md, lg).

overlap: sm | md | lg
overlap="sm"
S1
S2
S3
overlap="md" (Default)
M1
M2
M3
overlap="lg"
L1
L2
L3

Max Limit & Overflow Badge

Use the max prop to cap visible avatars and automatically display a +N excess count badge formatted with clean dark/light neutral colors.

max: number
A
B
C
+3

Hover with Tooltip

Enable tooltips by setting the showTooltip prop. Hovering over each avatar displays its corresponding title or alt label.

showTooltip: boolean
SJ
ED
AR

Pressable Avatars

Make avatars interactive by adding the isPressable prop. You can add onClick listeners to trigger custom actions, like showing toast notifications.

isPressable: boolean
SJ
ED
AR
API Reference

Props — AvatarGroup

Properties for configuring the AvatarGroup component.

PropTypeDefaultDescription
overlap'sm' | 'md' | 'lg''md'Overlap spacing tightness density between avatars.
orientation'horizontal' | 'vertical''horizontal'Stack direction scale for grouping avatars.
maxnumberMaximum number of avatars to display before rendering excess (+N) badge.
showTooltipbooleanfalseEnables showing tooltips above each avatar showing its title or alt description on hover.
isPressablebooleanfalseEnables scaling animations on mouse hover/press for nested avatars.