shadimage
nexus-labs
componentavatar-groupsource: ok · hit

Avatar Group

Overlapping avatar stack with per-avatar entrance animations (flip, slide, scale, bounce), four hover styles, name tooltips, and automatic color-coded fallback initials when no image is supplied.

npx shadcn add "https://nexus-ui.com/r/avatar-group.json"
free

Visual style

terminal-mono

Interaction

tooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationframer-motion

Registry dependencies

https://www.nexus-ui.com/api/registry/nexus-fontutils

Files2 · source cached 10/9/2026, 4:49:07 AM

next-ui/avatar-group/avatar-group.tsx
"use client";

import { useState } from "react";
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

export interface AvatarData {
  src?: string;
  alt?: string;
  /** Initials or short text shown when no image is provided. */
  fallback: string;
  /** Name shown in the tooltip on hover. */
  tooltip?: string;
  /** Background color for the fallback circle (CSS value). */
  bgColor?: string;
}

export interface AvatarGroupProps {
  avatars: AvatarData[];
  /** Avatar diameter in px. @default 40 */
  size?: number;
  /** Pixel overlap between adjacent avatars. @default 10 */
  overlap?: number;
  /** Width of the ring border around each avatar in px. @default 2 */
  borderWidth?: number;
  /** Explicit CSS color for the ring. Defaults to the page background color. */
  borderColor?: string;
  /** Show a tooltip on hover. @default true */
  showTooltips?: boolean;
  /** Hover lift animation style. @default "lift" */
  hoverAnimation?: "scale" | "lift" | "rotate" | "up" | "none";
  /** Scale multiplier applied during hover. @default 1.1 */
  hoverScale?: number;
  /** Entrance animation style. @default "scale" */
  entranceAnimation?: "none" | "fade" | "flip" | "slide" | "scale" | "bounce";
  /** Delay in seconds between each avatar's entrance. @default 0.05 */
  entranceStagger?: number;
  className?: string;
}

const SPRING = { type: "spring" as const, stiffness: 400, damping: 18, mass: 0.8 };

const DEFAULT_COLOURS = [
  "#06b6d4", "#8b5cf6", "#10b981", "#f59e0b",
  "#d946ef", "#0ea5e9", "#f43f5e", "#14b8a6",
];

function entranceInitial(type: AvatarGroupProps["entranceAnimation"]) {
  switch (type) {
    case "flip":   return { rotateY: -90, scale: 0.8, opacity: 0 };
    case "slide":  return { x: 16, opacity: 0 };
    case "scale":  return { scale: 0.8, opacity: 0 };
    case "bounce": return { scale: 0.8, y: 16, opacity: 0 };
    case "fade":   return { opacity: 0 };
    default:       return false as const;
  }
}

function entranceFinal(type: AvatarGroupProps["entranceAnimation"]) {
  switch (type) {
    case "flip":   return { rotateY: 0, scale: 1, opacity: 1 };
    case "slide":  return { x: 0, opacity: 1 };
    case "scale":  return { scale: 1, opacity: 1 };
    case "bounce": return { scale: 1, y: 0, opacity: 1 };
    case "fade":   return { opacity: 1 };
    default:       return {};
  }
}

function hoverTarget(type: AvatarGroupProps["hoverAnimation"], scale: number) {
  switch (type) {
    case "scale":  return { scale };
    case "lift":   return { y: -4, scale };
    case "rotate": return { rotate: 5, scale };
    case "up":     return { y: -8 };
    default:       return undefined;
  }
}

export function AvatarGroup({
  avatars,
  size = 40,
  overlap = 10,
  borderWidth = 2,
  borderColor,
  showTooltips = true,
  hoverAnimation = "lift",
  hoverScale = 1.1,
  entranceAnimation = "scale",
  entranceStagger = 0.05,
  className,
}: AvatarGroupProps) {
  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
  const reduce = useReducedMotion();

  const step = size - overlap;
  const totalWidth = avatars.length > 0
    ? avatars.length * size - (avatars.length - 1) * overlap
    : 0;

  const initial = reduce ? false : entranceInitial(entranceAnimation);
  const finalState = entranceFinal(entranceAnimation);
  const hoverAnim = reduce ? undefined : hoverTarget(hoverAnimation, hoverScale);

  return (
    <div
      className={cn("relative", className)}
      style={{ width: totalWidth, height: size }}
    >
      {avatars.map((avatar, index) => {
        const bgColor = avatar.bgColor ?? DEFAULT_COLOURS[index % DEFAULT_COLOURS.length];

        return (
          <motion.div
            key={index}
            className="absolute top-0 select-none"
            style={{
              left: index * step,
              width: size,
              height: size,
              zIndex: hoveredIndex === index ? avatars.length + 10 : avatars.length - index,
            }}
            initial={initial}
            animate={finalState}
            transition={{ ...SPRING, delay: reduce ? 0 : entranceStagger * index }}
            whileHover={hoverAnim}
            onHoverStart={() => setHoveredIndex(index)}
            onHoverEnd={() => setHoveredIndex(null)}
          >
            <div
              className={cn(
                "flex items-center justify-center rounded-full overflow-hidden cursor-pointer",
                !borderColor && "ring-2 ring-background dark:ring-zinc-900",
              )}
              style={{
                width: size,
                height: size,
                backgroundColor: bgColor,
                ...(borderColor
                  ? { boxShadow: `0 0 0 ${borderWidth}px ${borderColor}` }
                  : {}),
              }}
            >
              {avatar.src ? (
                <img
                  src={avatar.src}
                  alt={avatar.alt ?? avatar.tooltip ?? avatar.fallback}
                  width={size}
                  height={size}
                  className="h-full w-full object-cover"
                  onError={(e) => {
                    e.currentTarget.style.display = "none";
                    const sib = e.currentTarget.nextElementSibling as HTMLElement | null;
                    if (sib) sib.style.display = "flex";
                  }}
                />
              ) : null}
              <span
                className="flex items-center justify-center w-full h-full font-semibold text-white"
                style={{
                  fontSize: size * 0.35,
                  display: avatar.src ? "none" : "flex",
                }}
              >
                {avatar.fallback}
              </span>
            </div>

            {showTooltips && avatar.tooltip && (
              <AnimatePresence>
                {hoveredIndex === index && (
                  <motion.div
                    key="tip"
                    initial={{ opacity: 0, y: 4 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, y: 4 }}
                    transition={{ duration: 0.15 }}
                    className="pointer-events-none absolute z-50"
                    style={{ bottom: size + 6, left: "50%", transform: "translateX(-50%)" }}
                  >
                    <div className="rounded bg-zinc-900 px-2 py-1 text-[11px] font-medium text-white whitespace-nowrap dark:bg-zinc-700">
                      {avatar.tooltip}
                    </div>
                    <div className="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-zinc-900 dark:border-t-zinc-700" />
                  </motion.div>
                )}
              </AnimatePresence>
            )}
          </motion.div>
        );
      })}
    </div>
  );
}
next-ui/avatar-group/index.ts
export { AvatarGroup } from "./avatar-group";
export type { AvatarGroupProps, AvatarData } from "./avatar-group";

More from nexus-labs

Browse all components in this library →