Overlapping React avatars with one shared hover tooltip that morphs smoothly between people. Built with Motion and Tailwind CSS.
<AvatarGroup>
<Avatar className="size-10">
<AvatarImage src="https://i.pravatar.cc/300?img=1" />
<AvatarFallback>DR</AvatarFallback>
<AvatarTooltip className="w-50">
<div className="flex items-center justify-between p-3">
<div className="flex flex-col">
<p className="font-medium font-serif text-sm">Drake Reynolds</p>
<span className="text-muted-foreground text-xs">CEO</span>
</div>
</div>
</AvatarTooltip>
</Avatar>
</AvatarGroup><AvatarGroup>
<Avatar className="size-10">
<AvatarImage src="https://i.pravatar.cc/300?img=1" />
<AvatarFallback>DR</AvatarFallback>
<AvatarTooltip className="w-fit bg-black shadow-none">
<div className="flex items-center gap-8 p-2">
<div className="flex flex-col">
<p className="whitespace-nowrap font-medium font-serif text-white text-sm">
{avatar.name}
</p>
{avatar.hasJobRole && (
<span className="text-gray-300 text-xs">{avatar.jobRole}</span>
)}
</div>
{avatar.isHired && (
<SpringrLogo className="text-accent" size="xxs" />
)}
</div>
</AvatarTooltip>
</Avatar>
</AvatarGroup>Install the following dependencies:
npm install motion react-use-measurebun add motion react-use-measuredeno add motion react-use-measurepnpm add motion react-use-measureyarn add motion react-use-measureCopy and paste the following code into your project.
"use client";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
type ComponentProps,
createContext,
type FocusEvent as ReactFocusEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type RefObject,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import useMeasure from "react-use-measure";
import { cn } from "@/lib/utils";
const EASE = [0.22, 1, 0.36, 1] as const;
const SLIDE_MS = 0.35;
const OPEN_MS = 0.22;
const CLOSE_MS = 0.16;
const REDUCED_OPACITY_MS = 0.12;
const HOVER_CLOSE_DELAY_MS = 100;
const CAN_HOVER_MQ = "(hover: hover) and (pointer: fine)";
type Phase = "closed" | "open" | "closing";
type AvatarImageStatus = "error" | "idle" | "loaded" | "loading";
export function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (value) => {
for (const ref of refs) {
if (typeof ref === "function") {
ref(value);
} else if (ref) {
ref.current = value;
}
}
};
}
interface TooltipRecord {
className?: string;
content: ReactNode;
}
interface AvatarGroupContextValue {
activate: (id: string) => void;
activeId: string | null;
cancelClose: () => void;
direction: number;
finishClose: () => void;
heightRef: RefObject<number>;
layoutId: string;
phase: Phase;
previousId: string | null;
registerAvatar: (id: string, node: HTMLButtonElement | null) => void;
registerTooltip: (
id: string,
content: ReactNode,
className: string | undefined
) => void;
requestClose: () => void;
rootRef: RefObject<HTMLDivElement | null>;
scheduleClose: () => void;
sideOffset: number;
tooltipId: string;
tooltipsRef: RefObject<Map<string, TooltipRecord>>;
unregisterTooltip: (id: string) => void;
widthRef: RefObject<number>;
}
interface AvatarContextValue {
id: string;
setStatus: (status: AvatarImageStatus) => void;
status: AvatarImageStatus;
}
const AvatarGroupContext = createContext<AvatarGroupContextValue | null>(null);
const AvatarContext = createContext<AvatarContextValue | null>(null);
function useAvatarGroup(): AvatarGroupContextValue {
const context = useContext(AvatarGroupContext);
if (!context) {
throw new Error("Avatar components must be used inside <AvatarGroup>");
}
return context;
}
function useAvatar(): AvatarContextValue {
const context = useContext(AvatarContext);
if (!context) {
throw new Error(
"AvatarImage, AvatarFallback, and AvatarTooltip must be used inside <Avatar>"
);
}
return context;
}
function canHover(): boolean {
return window.matchMedia(CAN_HOVER_MQ).matches;
}
const FIT_WIDTH_CLASS = /(?:^|\s)(?:[\w-]+:)*w-(?:fit|max|min)\b/;
function usesFitWidth(className: string | undefined): boolean {
return className !== undefined && FIT_WIDTH_CLASS.test(className);
}
function slotSize(measured: number, initial: number): number | "auto" {
if (measured > 0) {
return measured;
}
if (initial > 0) {
return initial;
}
return "auto";
}
function slotInitialSize(
fitWidth: boolean,
initialHeight: number,
initialWidth: number
): false | { height?: number; width?: number } {
const hasHeight = initialHeight > 0;
const hasWidth = fitWidth && initialWidth > 0;
if (!(hasHeight || hasWidth)) {
return false;
}
return {
...(hasHeight ? { height: initialHeight } : {}),
...(hasWidth ? { width: initialWidth } : {}),
};
}
function opacityDuration(reduceMotion: boolean, closing: boolean): number {
if (reduceMotion) {
return REDUCED_OPACITY_MS;
}
if (closing) {
return CLOSE_MS;
}
return OPEN_MS;
}
const contentSlideVariants: Variants = {
center: {
opacity: 1,
transition: { bounce: 0.1, duration: 0.35, type: "spring" },
x: "0%",
},
enter: (direction: number) => ({
filter: "blur(0px)",
opacity: direction === 0 ? 1 : 0,
x: direction === 0 ? "0%" : direction > 0 ? "28%" : "-28%",
}),
exit: (direction: number) => ({
filter: "blur(4px)",
opacity: direction === 0 ? 1 : 0,
transition: { bounce: 0.1, duration: 0.3, type: "spring" },
x: direction === 0 ? "0%" : direction > 0 ? "-28%" : "28%",
}),
};
const reducedSlideVariants: Variants = {
center: { opacity: 1, transition: { duration: 0 }, x: "0%" },
enter: { opacity: 1, x: "0%" },
exit: { opacity: 1, transition: { duration: 0 }, x: "0%" },
};
type AvatarGroupProps = ComponentProps<"div"> & {
sideOffset?: number;
};
function AvatarGroup({
children,
className,
onMouseEnter,
onMouseLeave,
ref,
sideOffset = 10,
...props
}: AvatarGroupProps) {
const reactId = useId();
const layoutId = `${reactId}-avatars-tooltip`;
const tooltipId = `${reactId}-tooltip`;
const rootRef = useRef<HTMLDivElement>(null);
const heightRef = useRef(0);
const widthRef = useRef(0);
const tooltipsRef = useRef(new Map<string, TooltipRecord>());
const avatarEls = useRef(new Map<string, HTMLButtonElement>());
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [previousId, setPreviousId] = useState<string | null>(null);
const [direction, setDirection] = useState(0);
const [phase, setPhase] = useState<Phase>("closed");
const activeIdRef = useRef(activeId);
const phaseRef = useRef(phase);
activeIdRef.current = activeId;
phaseRef.current = phase;
const registerAvatar = useCallback(
(id: string, node: HTMLButtonElement | null) => {
if (node) {
avatarEls.current.set(id, node);
return;
}
avatarEls.current.delete(id);
},
[]
);
const registerTooltip = useCallback(
(id: string, content: ReactNode, classNameInternal: string | undefined) => {
tooltipsRef.current.set(id, { className: classNameInternal, content });
},
[]
);
const resolveDirection = useCallback((fromId: string, toId: string) => {
const from = avatarEls.current.get(fromId);
const to = avatarEls.current.get(toId);
if (!(from && to)) {
return 0;
}
const delta =
to.getBoundingClientRect().left - from.getBoundingClientRect().left;
if (delta === 0) {
return 0;
}
return delta > 0 ? 1 : -1;
}, []);
const cancelClose = useCallback(() => {
if (closeTimer.current !== null) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
if (phaseRef.current === "closing") {
phaseRef.current = "open";
setPhase("open");
}
}, []);
const requestClose = useCallback(() => {
if (phaseRef.current !== "open" || activeIdRef.current === null) {
return;
}
phaseRef.current = "closing";
setPhase("closing");
}, []);
const unregisterTooltip = useCallback(
(id: string) => {
tooltipsRef.current.delete(id);
if (activeIdRef.current === id && phaseRef.current !== "closed") {
requestClose();
}
},
[requestClose]
);
const scheduleClose = useCallback(() => {
if (closeTimer.current !== null) {
clearTimeout(closeTimer.current);
}
closeTimer.current = setTimeout(() => {
closeTimer.current = null;
requestClose();
}, HOVER_CLOSE_DELAY_MS);
}, [requestClose]);
const finishClose = useCallback(() => {
if (phaseRef.current !== "closing") {
return;
}
phaseRef.current = "closed";
activeIdRef.current = null;
heightRef.current = 0;
widthRef.current = 0;
setDirection(0);
setPreviousId(null);
setActiveId(null);
setPhase("closed");
}, []);
const activate = useCallback(
(id: string) => {
if (!tooltipsRef.current.has(id)) {
if (phaseRef.current !== "closed") {
requestClose();
}
return;
}
const currentId = activeIdRef.current;
const currentPhase = phaseRef.current;
const isSame = currentId === id && currentPhase !== "closed";
if (isSame) {
if (currentPhase === "closing") {
setPhase("open");
}
return;
}
const hasOpenTooltip = currentId !== null && currentPhase !== "closed";
setDirection(
hasOpenTooltip && currentId ? resolveDirection(currentId, id) : 0
);
setPreviousId(hasOpenTooltip ? currentId : null);
setActiveId(id);
setPhase("open");
},
[requestClose, resolveDirection]
);
const handleRootMouseEnter = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (canHover()) {
cancelClose();
}
onMouseEnter?.(event);
},
[cancelClose, onMouseEnter]
);
const handleRootMouseLeave = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (canHover()) {
scheduleClose();
}
onMouseLeave?.(event);
},
[onMouseLeave, scheduleClose]
);
useEffect(
() => () => {
if (closeTimer.current !== null) {
clearTimeout(closeTimer.current);
}
},
[]
);
useEffect(() => {
if (phase === "closed") {
return;
}
const onPointerDown = (event: PointerEvent) => {
const { target } = event;
if (!(target instanceof Node)) {
return;
}
if (rootRef.current?.contains(target)) {
return;
}
requestClose();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
requestClose();
}
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [phase, requestClose]);
const contextValue = useMemo(
() => ({
activate,
activeId,
cancelClose,
direction,
finishClose,
heightRef,
layoutId,
phase,
previousId,
registerAvatar,
registerTooltip,
requestClose,
rootRef,
scheduleClose,
sideOffset,
tooltipId,
tooltipsRef,
unregisterTooltip,
widthRef,
}),
[
activate,
activeId,
cancelClose,
direction,
finishClose,
layoutId,
phase,
previousId,
registerAvatar,
registerTooltip,
requestClose,
scheduleClose,
sideOffset,
tooltipId,
unregisterTooltip,
]
);
return (
<AvatarGroupContext.Provider value={contextValue}>
<LayoutGroup>
<div
{...props}
className={cn("flex items-center -space-x-2", className)}
data-slot="avatars"
onMouseEnter={handleRootMouseEnter}
onMouseLeave={handleRootMouseLeave}
ref={mergeRefs(ref, rootRef)}
>
{children}
</div>
</LayoutGroup>
</AvatarGroupContext.Provider>
);
}
AvatarGroup.displayName = "AvatarGroup";
type AvatarProps = ComponentProps<"button">;
function Avatar({
children,
className,
onBlur,
onClick,
onFocus,
onMouseEnter,
ref,
...props
}: AvatarProps) {
const id = useId();
const {
activate,
activeId,
cancelClose,
phase,
registerAvatar,
requestClose,
rootRef,
scheduleClose,
tooltipId,
} = useAvatarGroup();
const [status, setStatus] = useState<AvatarImageStatus>("idle");
const isCurrent = activeId === id && phase !== "closed";
const avatarContext = useMemo(
() => ({ id, setStatus, status }),
[id, status]
);
const setButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
registerAvatar(id, node);
},
[id, registerAvatar]
);
const handleBlur = useCallback(
(event: ReactFocusEvent<HTMLButtonElement>) => {
const next = event.relatedTarget;
const focusStayedInside =
next instanceof Node && rootRef.current?.contains(next) === true;
if (!focusStayedInside) {
scheduleClose();
}
onBlur?.(event);
},
[onBlur, rootRef, scheduleClose]
);
const handleClick = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || canHover()) {
return;
}
if (activeId === id && phase === "open") {
requestClose();
return;
}
activate(id);
},
[activate, activeId, id, onClick, phase, requestClose]
);
const handleFocus = useCallback(
(event: ReactFocusEvent<HTMLButtonElement>) => {
cancelClose();
activate(id);
onFocus?.(event);
},
[activate, cancelClose, id, onFocus]
);
const handleMouseEnter = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
if (canHover()) {
cancelClose();
activate(id);
}
onMouseEnter?.(event);
},
[activate, cancelClose, id, onMouseEnter]
);
return (
<AvatarContext.Provider value={avatarContext}>
<div
className={cn(
"relative shrink-0 transition-transform duration-200 hover:-translate-y-px",
isCurrent && "z-10"
)}
data-active={isCurrent ? "true" : "false"}
data-slot="avatar"
>
<button
{...props}
aria-describedby={isCurrent ? tooltipId : undefined}
className={cn(
"relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-muted align-middle outline-none ring-2 ring-background focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2",
className
)}
onBlur={handleBlur}
onClick={handleClick}
onFocus={handleFocus}
onMouseEnter={handleMouseEnter}
ref={mergeRefs(ref, setButtonRef)}
type="button"
>
{children}
</button>
{isCurrent ? <AvatarTooltipFlyer id={id} /> : null}
</div>
</AvatarContext.Provider>
);
}
Avatar.displayName = "Avatar";
type AvatarImageProps = ComponentProps<"img">;
function AvatarImage({
className,
onError,
onLoad,
ref,
src,
...props
}: AvatarImageProps) {
const { setStatus, status } = useAvatar();
const imageRef = useRef<HTMLImageElement>(null);
const onErrorInternal = useCallback(
(event: React.SyntheticEvent<HTMLImageElement, Event>) => {
setStatus("error");
onError?.(event);
},
[setStatus, onError]
);
const onLoadInternal = useCallback(
(event: React.SyntheticEvent<HTMLImageElement, Event>) => {
setStatus("loaded");
onLoad?.(event);
},
[setStatus, onLoad]
);
useLayoutEffect(() => {
const image = imageRef.current;
if (image?.complete && image.naturalWidth > 0) {
setStatus("loaded");
return;
}
setStatus("loading");
}, [setStatus]);
useLayoutEffect(
() => () => {
setStatus("idle");
},
[setStatus]
);
if (status === "error") {
return null;
}
return (
/* biome-ignore lint/performance/noImgElement: not needed */
<img
aria-label={props.alt}
height={100}
width={100}
{...props}
className={cn("absolute inset-0 size-full object-cover", className)}
data-slot="avatar-image"
onError={onErrorInternal}
onLoad={onLoadInternal}
ref={mergeRefs(ref, imageRef)}
src={src}
/>
);
}
AvatarImage.displayName = "AvatarImage";
type AvatarFallbackProps = ComponentProps<"span">;
function AvatarFallback({
children,
className,
...props
}: AvatarFallbackProps) {
const { status } = useAvatar();
if (status === "loaded") {
return null;
}
return (
<span
className={cn(
"flex size-full items-center justify-center font-medium text-[10px] text-muted-foreground",
className
)}
data-slot="avatar-fallback"
{...props}
>
{children}
</span>
);
}
AvatarFallback.displayName = "AvatarFallback";
type AvatarTooltipProps = ComponentProps<"div">;
function AvatarTooltip({ children, className }: AvatarTooltipProps) {
const { id } = useAvatar();
const { registerTooltip, unregisterTooltip } = useAvatarGroup();
registerTooltip(id, children, className);
useLayoutEffect(() => {
registerTooltip(id, children, className);
return () => {
unregisterTooltip(id);
};
}, [children, className, id, registerTooltip, unregisterTooltip]);
return null;
}
AvatarTooltip.displayName = "AvatarTooltip";
function AvatarTooltipFlyer({ id }: { id: string }) {
const {
direction,
finishClose,
heightRef,
layoutId,
phase,
previousId,
sideOffset,
tooltipId,
tooltipsRef,
widthRef,
} = useAvatarGroup();
const reduceMotion = useReducedMotion() === true;
const [measureRef, bounds] = useMeasure();
const initialHeight = useRef(heightRef.current).current;
const initialWidth = useRef(widthRef.current).current;
const [shownId, setShownId] = useState(() => previousId ?? id);
const tooltip = tooltipsRef.current.get(id);
const shown = tooltipsRef.current.get(shownId);
const closing = phase === "closing";
const fitWidth = usesFitWidth(tooltip?.className);
const targetHeight = slotSize(bounds.height, initialHeight);
const targetWidth = slotSize(bounds.width, initialWidth);
const tweenHeight = !reduceMotion && initialHeight > 0 && bounds.height > 0;
const tweenWidth =
fitWidth && !reduceMotion && initialWidth > 0 && bounds.width > 0;
const tweenSize = tweenHeight || tweenWidth;
const contentWidthClass = fitWidth ? "w-max" : "w-full";
const slotInitial = slotInitialSize(fitWidth, initialHeight, initialWidth);
useLayoutEffect(() => {
if (bounds.height > 0) {
heightRef.current = bounds.height;
}
if (fitWidth && bounds.width > 0) {
widthRef.current = bounds.width;
}
}, [bounds.height, bounds.width, fitWidth, heightRef, widthRef]);
// The flyer remounts on the next avatar, so seed the outgoing content and
// swap the key after commit. Presence then runs enter and exit together.
useLayoutEffect(() => {
if (previousId !== null && shownId !== id) {
setShownId(id);
}
}, [id, previousId, shownId]);
const handleOpacityComplete = useCallback(() => {
if (closing) {
finishClose();
}
}, [closing, finishClose]);
if (!tooltip) {
return null;
}
return (
<div
className="absolute bottom-full left-1/2 z-20 -translate-x-1/2"
style={{ paddingBottom: sideOffset }}
>
<motion.div
initial={false}
layout="position"
layoutId={layoutId}
transition={{
layout: reduceMotion
? { duration: 0 }
: { duration: SLIDE_MS, ease: EASE },
}}
>
<motion.div
animate={
closing
? { opacity: 0, scale: 0.98, y: 2 }
: { opacity: 1, scale: 1, y: 0 }
}
className={cn(
"w-50 rounded-xl bg-popover text-left text-popover-foreground shadow-lg ring-1 ring-foreground/10",
tooltip.className,
fitWidth && "w-auto"
)}
id={tooltipId}
initial={
previousId === null ? { opacity: 0, scale: 0.98, y: 2 } : false
}
onAnimationComplete={handleOpacityComplete}
role="tooltip"
transition={{
duration: opacityDuration(reduceMotion, closing),
ease: EASE,
}}
>
<motion.div
animate={
fitWidth
? { height: targetHeight, width: targetWidth }
: { height: targetHeight }
}
className={cn(
"relative overflow-hidden rounded-[inherit]",
!fitWidth && "w-full"
)}
initial={slotInitial}
transition={
tweenSize ? { duration: SLIDE_MS, ease: EASE } : { duration: 0 }
}
>
<AnimatePresence
custom={direction}
initial={false}
mode="popLayout"
>
<motion.div
animate="center"
className={contentWidthClass}
custom={direction}
exit="exit"
initial="enter"
key={shownId}
variants={
reduceMotion ? reducedSlideVariants : contentSlideVariants
}
>
<div
className={contentWidthClass}
ref={shownId === id ? measureRef : undefined}
>
{shown?.content}
</div>
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
</motion.div>
</div>
);
}
export type {
AvatarFallbackProps,
AvatarGroupProps,
AvatarImageProps,
AvatarProps,
AvatarTooltipProps,
};
export { Avatar, AvatarFallback, AvatarGroup, AvatarImage, AvatarTooltip };<AvatarGroup>
<Avatar className="size-10">
<AvatarImage alt="Drake Reynolds" src="/drake.jpg" />
<AvatarFallback>DR</AvatarFallback>
<AvatarTooltip className="w-50">
<div className="p-3">
<p>Drake Reynolds</p>
</div>
</AvatarTooltip>
</Avatar>
</AvatarGroup>If an avatar has no AvatarTooltip, hovering or focusing it does not open a flyer.
By default, AvatarTooltip has a width of w-50. Pass w-fit to size to content.
sideOffset on AvatarGroup is the gap between the avatar and the tooltip (default 10).
prefers-reduced-motion shortens opacity and skips slide, height, and width tweens.role="tooltip" and aria-describedby on the active avatar.Inherits native div props, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Merged onto the row. Default includes -space-x-2. |
| children | ReactNode | - | Avatar children. |
| sideOffset | number | 10 | Space between the avatar and the tooltip. |
Inherits native button props.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Merged onto the circular button (default size-8). |
| children | ReactNode | - | AvatarImage, AvatarFallback, and optional AvatarTooltip. |
Inherits native img props. Hidden after a load error so AvatarFallback shows.
Inherits native span props. Shown until the image has loaded.
Inherits className and children from a div.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Tooltip box styles. Default width is w-50. |
| children | ReactNode | - | Tooltip content. |