Avatar Stack
Overlapping avatars that lift and reveal a tooltip on hover.
AC
LP
NK
MR
EB
+12Joined by 2,000+ designers this month
components/ui/avatar-stack.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
type Person = { name: string; role: string };
const people: Person[] = [
{ name: "Ava Chen", role: "Design" },
{ name: "Liam Patel", role: "Engineering" },
{ name: "Noah Kim", role: "Product" },
{ name: "Mia Rossi", role: "Marketing" },
{ name: "Ethan Brooks", role: "Sales" },
];
const tones = ["bg-blue-600", "bg-violet-600", "bg-emerald-600", "bg-amber-500", "bg-rose-500"];
export function AvatarStack({ items = people, extra = 12 }: { items?: Person[]; extra?: number }) {
const [hovered, setHovered] = useState<number | null>(null);
return (
<div className="flex items-center">
{items.map((p, i) => (
<div
key={p.name}
className="relative -ml-3 first:ml-0"
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
>
<AnimatePresence>
{hovered === i && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.95 }}
className="absolute -top-14 left-1/2 z-30 -translate-x-1/2 whitespace-nowrap rounded-lg bg-zinc-900 px-3 py-1.5 text-center shadow-lg"
>
<p className="text-xs font-medium text-white">{p.name}</p>
<p className="text-[10px] text-zinc-400">{p.role}</p>
</motion.div>
)}
</AnimatePresence>
<motion.span
whileHover={{ y: -6, scale: 1.08 }}
transition={{ type: "spring", stiffness: 400, damping: 18 }}
style={{ zIndex: hovered === i ? 20 : items.length - i }}
className={`relative grid size-11 cursor-pointer place-items-center rounded-full text-sm font-semibold text-white ring-4 ring-white ${tones[i % tones.length]}`}
>
{p.name.split(" ").map((n) => n[0]).join("")}
</motion.span>
</div>
))}
{extra > 0 && (
<span className="relative -ml-3 grid size-11 place-items-center rounded-full bg-zinc-100 text-xs font-semibold text-zinc-600 ring-4 ring-white">
+{extra}
</span>
)}
</div>
);
}
export default function Demo() {
return (
<div className="flex flex-col items-center gap-4 pt-10">
<AvatarStack />
<p className="text-sm text-zinc-500">
Joined by <span className="font-medium text-zinc-900">2,000+</span> designers this month
</p>
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/avatar-stack.tsxcomponents/ui/avatar-stack.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { useState } from "react"; type Person = { name: string; role: string }; const people: Person[] = [ { name: "Ava Chen", role: "Design" }, { name: "Liam Patel", role: "Engineering" }, { name: "Noah Kim", role: "Product" }, { name: "Mia Rossi", role: "Marketing" }, { name: "Ethan Brooks", role: "Sales" }, ]; const tones = ["bg-blue-600", "bg-violet-600", "bg-emerald-600", "bg-amber-500", "bg-rose-500"]; export function AvatarStack({ items = people, extra = 12 }: { items?: Person[]; extra?: number }) { const [hovered, setHovered] = useState<number | null>(null); return ( <div className="flex items-center"> {items.map((p, i) => ( <div key={p.name} className="relative -ml-3 first:ml-0" onMouseEnter={() => setHovered(i)} onMouseLeave={() => setHovered(null)} > <AnimatePresence> {hovered === i && ( <motion.div initial={{ opacity: 0, y: 8, scale: 0.9 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 4, scale: 0.95 }} className="absolute -top-14 left-1/2 z-30 -translate-x-1/2 whitespace-nowrap rounded-lg bg-zinc-900 px-3 py-1.5 text-center shadow-lg" > <p className="text-xs font-medium text-white">{p.name}</p> <p className="text-[10px] text-zinc-400">{p.role}</p> </motion.div> )} </AnimatePresence> <motion.span whileHover={{ y: -6, scale: 1.08 }} transition={{ type: "spring", stiffness: 400, damping: 18 }} style={{ zIndex: hovered === i ? 20 : items.length - i }} className={`relative grid size-11 cursor-pointer place-items-center rounded-full text-sm font-semibold text-white ring-4 ring-white ${tones[i % tones.length]}`} > {p.name.split(" ").map((n) => n[0]).join("")} </motion.span> </div> ))} {extra > 0 && ( <span className="relative -ml-3 grid size-11 place-items-center rounded-full bg-zinc-100 text-xs font-semibold text-zinc-600 ring-4 ring-white"> +{extra} </span> )} </div> ); } export default function Demo() { return ( <div className="flex flex-col items-center gap-4 pt-10"> <AvatarStack /> <p className="text-sm text-zinc-500"> Joined by <span className="font-medium text-zinc-900">2,000+</span> designers this month </p> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.