Data Display

Avatar Group

Overlapping avatars where hover lifts the pointed avatar and its neighbors with a distance falloff, collapsing extras into a "+N" chip.

Install

npx shadcn@latest add @paragon/avatar-group

Also installs: avatar

avatar-group.tsx

"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>
  );
}