Animated Avatar Group
Animated Avatar Group is a React and Tailwind CSS media component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
media
What it is
Animated Avatar Group is a React and Tailwind CSS media component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Animated Avatar Group
"use client";
import { cn } from "@/lib/utils";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
export type AvatarData = {
src: string;
alt: string;
href?: string;
};
export type AnimatedAvatarGroupProps = {
avatars: AvatarData[];
maxVisible?: number;
size?: number;
overlap?: number;
className?: string;
expandOnHover?: boolean;
};
const AnimatedAvatarGroup = ({
avatars,
maxVisible = 4,
size = 40,
overlap = 0.3,
className,
expandOnHover = true,
}: AnimatedAvatarGroupProps) => {
const shouldReduceMotion = useReducedMotion();
const [isHovered, setIsHovered] = useState(false);
const [isHoverDevice, setIsHoverDevice] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(hover: hover) and (pointer: fine)");
setIsHoverDevice(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setIsHoverDevice(event.matches);
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
const visibleAvatars = avatars.slice(0, maxVisible);
const hiddenCount = avatars.length - maxVisible;
const hasHiddenAvatars = hiddenCount > 0;
const overlapPx = size * overlap;
const expanded = expandOnHover && isHoverDevice && isHovered;
const springTransition = shouldReduceMotion
? { duration: 0 }
: { bounce: 0.1, duration: 0.25, type: "spring" as const };
return (
<motion.div
aria-label="Avatar group"
className={cn("flex items-center", className)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
role="group"
>
{visibleAvatars.map((avatar, index) => {
const marginLeft = index === 0 ? 0 : expanded ? 4 : -overlapPx;
const content = (
<motion.img
alt={avatar.alt}
animate={
shouldReduceMotion
? { opacity: 1 }
: {
opacity: 1,
scale: expanded ? 1.05 : 1,
}
}
className="rounded-full object-cover"
draggable={false}
height={size}
initial={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }
}
src={avatar.src}
style={{
height: size,
width: size,
}}
transition={{
...springTransition,
delay: shouldReduceMotion ? 0 : index * 0.03,
}}
width={size}
/>
);
return (
<motion.div
animate={
shouldReduceMotion
? { marginLeft, opacity: 1 }
: { marginLeft, opacity: 1 }
}
className="relative"
key={avatar.src}
style={{
height: size,
width: size,
zIndex: visibleAvatars.length - index,
}}
transition={{
...springTransition,
delay: shouldReduceMotion ? 0 : index * 0.03,
}}
>
<div
className="rounded-full border-2 border-background"
style={{
height: size,
width: size,
}}
>
{avatar.href ? (
<a aria-label={avatar.alt} href={avatar.href} rel="noopener">
{content}
</a>
) : (
content
)}
</div>
</motion.div>
);
})}
{hasHiddenAvatars ? (
<motion.div
animate={
shouldReduceMotion
? {
marginLeft: expanded ? 4 : -overlapPx,
opacity: 1,
}
: {
marginLeft: expanded ? 4 : -overlapPx,
opacity: 1,
}
}
className="relative flex items-center justify-center rounded-full border-2 border-background bg-muted"
style={{
height: size,
width: size,
zIndex: 0,
}}
transition={{
...springTransition,
delay: shouldReduceMotion ? 0 : visibleAvatars.length * 0.03,
}}
>
<span
className="font-medium text-muted-foreground"
style={{ fontSize: size * 0.3 }}
>
{`+${hiddenCount}`}
</span>
</motion.div>
) : null}
</motion.div>
);
};
export default AnimatedAvatarGroup;