Avatar Group
Stack multiple avatars together with smooth cubic-bezier hover expansion, orientation support (horizontal or vertical), count truncation, and clean dark/light neutral theme styling.
AvatarGroup component for displaying overlapping avatar stacks with butter-smooth hover expansion transitions and orientation control.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, type AvatarProps } from "@/components/ui/avatar/avatar";
export type AvatarGroupOrientation = "horizontal" | "vertical";
export interface AvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
max?: number;
total?: number;
orientation?: AvatarGroupOrientation;
size?: AvatarProps["size"];
color?: AvatarProps["color"];
radius?: AvatarProps["radius"];
isBordered?: boolean;
isGrid?: boolean;
isDisabled?: boolean;
renderCount?: (count: number) => React.ReactNode;
}
const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
(
{
children,
max,
total,
orientation = "horizontal",
size = "md",
color = "default",
radius = "full",
isBordered = true,
isGrid = false,
isDisabled = 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";
return (
<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
? "flex-col -space-y-3 hover:-space-y-1.5 items-start"
: "items-center -space-x-3 hover:-space-x-1.5",
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,
className: cn(
"ring-2 ring-white dark:ring-zinc-900 transition-all duration-300 ease-out",
child.props.className
),
});
return (
<div
key={index}
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>
);
})}
{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>
);
}
);
AvatarGroup.displayName = "AvatarGroup";
export { AvatarGroup };
Default
A standard avatar group component stacking multiple user avatars with smooth hover expansion transitions.
Orientation (Horizontal & Vertical)
Control the stack direction using orientation (horizontal or vertical). Vertical stacks expand vertically and translate avatars smoothly to the right on hover.
horizontal | verticalPressable Avatar Stack (isPressable)
Combine AvatarGroup with isPressable to allow users to click individual avatars in an overlapping stack.
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.
Grid Layout (isGrid)
Display avatars in a spaced, non-overlapping grid layout instead of a stack using isGrid.
Sizes
Pass size to the AvatarGroup container to scale all child avatars uniformly.
xs | sm | md | lg | xl | 2xl | 3xlColor Themes
Propagate color ring themes to all child avatars via the color prop. Stacked vertically for clear visual comparison.
default | primary | secondary | accent | success | warning | dangerProps — AvatarGroup
Properties for configuring the AvatarGroup component.
| Prop | Type | Default | Description |
|---|---|---|---|
| orientation | 'horizontal' | 'vertical' | 'horizontal' | Stack direction scale for grouping avatars. |
| max | number | — | Maximum number of avatars to display before rendering excess (+N) badge. |
| total | number | — | Explicit total user count for calculating overflow badge numbers. |
| isGrid | boolean | false | Renders avatars in a spaced grid layout instead of an overlapping stack. |
| isDisabled | boolean | false | Disables interaction and applies grayscale filter to all avatars. |
| size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'md' | Uniform size scale propagated to all child avatars. |
| color | 'default' | 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger' | 'default' | Color theme applied to outer borders of all avatars in the stack. |
| radius | 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full' | 'full' | Border radius scale applied to all avatars in the group. |
| isBordered | boolean | true | Enables outer ring borders around each avatar in the stack. |
| renderCount | (count: number) => ReactNode | — | Custom render function for the excess count badge (+N). |