Overlapping avatars where hover lifts the pointed avatar and its neighbors with a distance falloff, collapsing extras into a "+N" chip.
npx shadcn@latest add @paragon/avatar-groupAlso installs: avatar
"use client";
import * as React from "react";
import { avatarSizes, type AvatarSize } from "@/registry/paragon/ui/avatar";
import { cn } from "@/lib/utils";
export interface AvatarGroupProps extends React.ComponentProps<"ul"> {
/** Max avatars shown before collapsing the rest into a "+N" chip. */
max?: number;
/** Sizes the overflow chip to match the avatars it sits beside. */
size?: AvatarSize;
/** Peak hover lift in px. */
lift?: number;
/** How much of the lift each neighbor inherits (0–1). */
falloff?: number;
/** Disables the hover lift. */
static?: boolean;
}
/**
* Overlapping avatars with a distance-falloff hover lift: the hovered
* avatar rises `lift` px and each neighbor inherits `falloff`× the lift of
* the one before it, so the row swells around the pointer. Transforms are
* written straight to the elements (no CSS-variable fan-out), with the
* easing set inline before each mutation — ease-out on the way up,
* ease-exit on the way back down. Hover-only, pointer-fine-only, and
* skipped entirely under reduced motion.
*/
export function AvatarGroup({
max,
size = "default",
lift = 4,
falloff = 0.45,
static: isStatic = false,
className,
children,
...props
}: AvatarGroupProps) {
const items = React.Children.toArray(children);
const visible = max !== undefined && max < items.length ? items.slice(0, max) : items;
const overflow = items.length - visible.length;
const count = visible.length + (overflow > 0 ? 1 : 0);
const itemRefs = React.useRef<(HTMLLIElement | null)[]>([]);
const offsets = React.useRef<number[]>([]);
const apply = React.useCallback((targets: number[]) => {
targets.forEach((offset, j) => {
const el = itemRefs.current[j];
if (!el) return;
const prev = offsets.current[j] ?? 0;
// Direction-aware easing, set before the transform mutation:
// rising responds on ease-out, settling falls away on ease-exit.
el.style.transitionTimingFunction =
Math.abs(offset) >= Math.abs(prev) ? "var(--ease-out)" : "var(--ease-exit)";
el.style.transform = offset === 0 ? "" : `translateY(${offset}px)`;
offsets.current[j] = offset;
});
}, []);
const handleEnter = (index: number) => {
if (isStatic) return;
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
apply(
Array.from({ length: count }, (_, j) => -(lift * Math.pow(falloff, Math.abs(index - j)))),
);
};
const handleLeave = () => {
if (isStatic) return;
apply(Array.from({ length: count }, () => 0));
};
return (
<ul
data-slot="avatar-group"
className={cn("flex items-center -space-x-2", className)}
onPointerLeave={handleLeave}
{...props}
>
{visible.map((child, i) => (
<li
key={i}
ref={(el) => {
itemRefs.current[i] = el;
}}
onPointerEnter={() => handleEnter(i)}
style={{ zIndex: count - i }}
className="relative transition-transform duration-(--duration-quick) will-change-transform"
>
{child}
</li>
))}
{overflow > 0 && (
<li
ref={(el) => {
itemRefs.current[visible.length] = el;
}}
onPointerEnter={() => handleEnter(visible.length)}
style={{ zIndex: 0 }}
className="relative transition-transform duration-(--duration-quick) will-change-transform"
>
<span
className={cn(
"flex items-center justify-center rounded-full bg-muted font-medium text-muted-foreground tabular-nums ring-2 ring-background select-none",
avatarSizes[size],
)}
aria-label={`${overflow} more`}
>
+{overflow}
</span>
</li>
)}
</ul>
);
}