"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ComponentPropsWithRef, type PointerEvent, useState } from "react"; import { Tooltip, TooltipProvider } from "@/components/motion/tooltip"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; export interface AvatarGroupMember { id: string; name: string; /** Image URL. Without one, or if it fails to load, initials show. */ src?: string; } const SIZES = { sm: { box: "size-7 text-[10px]", px: 28 }, md: { box: "size-9 text-xs", px: 36 }, lg: { box: "size-11 text-sm", px: 44 }, } as const; type AvatarSize = keyof typeof SIZES; function initials(name: string) { const parts = name.trim().split(/\s+/).filter(Boolean); const letters = parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : (parts[0] ?? "?").slice(0, 2); return letters.toUpperCase(); } export interface AvatarProps extends Omit, "children"> { name: string; src?: string; size?: AvatarSize; } /** One avatar: the image, or initials when there is no image or it fails. */ export function Avatar({ name, src, size = "md", className, ...props }: AvatarProps) { const [failedSrc, setFailedSrc] = useState(); const showImage = src && failedSrc !== src; return ( {showImage ? ( // biome-ignore lint/performance/noImgElement: a portable component; avatars come from any host and fall back to initials on error {name} setFailedSrc(src)} className="size-full object-cover" /> ) : ( <> {name} )} ); } export interface AvatarGroupProps extends Omit, "children"> { members: AvatarGroupMember[]; /** Avatars shown before the rest collapse into a +N count. */ max?: number; size?: AvatarSize; /** Whether every member is shown. Pair with `onExpandedChange`. */ expanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; /** Accessible name for the list, such as "Project members". */ label?: string; } /** * Overlapping avatars that fan apart on hover, lift the one under the pointer * with its name, and expand from a +N count to show everyone. */ export function AvatarGroup({ members, max = 4, size = "md", expanded, defaultExpanded = false, onExpandedChange, label = "Members", className, ...props }: AvatarGroupProps) { const reduce = useReducedMotion(); const canHover = useHoverCapable(); const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); const isExpanded = expanded ?? internalExpanded; // Index of the avatar under a mouse pointer; the +N button counts as the // last slot. const [hovered, setHovered] = useState(null); const limit = Math.max(1, max); const overflow = Math.max(0, members.length - limit); const visible = isExpanded ? members : members.slice(0, limit); const px = SIZES[size].px; // Avatars overlap by a third of their width. Hovering one parts the row // around it: everything before slides back, everything after slides on, // so the hovered avatar shows in full while the group stays in place. const overlap = Math.round(px / 3); const push = Math.round(overlap * 0.75); const parting = !reduce && canHover && hovered !== null; const offset = (index: number) => !parting || index === hovered ? 0 : index < (hovered ?? 0) ? -push : push; const hoverHandlers = (index: number) => ({ onPointerEnter: (event: PointerEvent) => { if (event.pointerType === "mouse") setHovered(index); }, }); const toggle = () => { const next = !isExpanded; if (expanded === undefined) setInternalExpanded(next); onExpandedChange?.(next); }; return (
{ props.onPointerLeave?.(event); setHovered(null); }} className={cn("inline-flex items-center", className)} >
    {visible.map((member, index) => ( ))} {overflow > 0 ? ( ) : null}
); }