A row of overlapping avatars for showing who is on something. Each person is a coloured circle with initials and a white ring. Pointing at one, or tabbing to it, lifts it above its neighbours, scales it slightly and shows a dark tooltip with the name and role; the same details appear in a line of text below, so touch and screen reader users get them too. A grey chip sums up the people who do not fit. The demo places the large stack in a project card and reuses a small static version on each task row. It needs very little width and works unchanged on phones.
// Adapted from Animata (https://animata.design/docs/list/avatar-list)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { CheckCircle2, Circle, UserPlus } from "lucide-react";
const people = [
{ name: "Amara Osei", role: "Product lead", initials: "AO", tone: "bg-rose-500" },
{ name: "Jonas Lindqvist", role: "Interface designer", initials: "JL", tone: "bg-sky-600" },
{ name: "Priya Raman", role: "Front-end developer", initials: "PR", tone: "bg-emerald-600" },
{ name: "Mateo Alvarez", role: "Content strategist", initials: "MA", tone: "bg-amber-600" },
{ name: "Hana Kobayashi", role: "Researcher", initials: "HK", tone: "bg-violet-600" },
{ name: "Tobias Brandt", role: "Back-end developer", initials: "TB", tone: "bg-teal-600" },
];
const EXTRA = 4; // people not shown, summed up in the "+4" chip
const tasks = [
{ title: "Rewrite the booking flow copy", done: true, who: [3, 0] },
{ title: "Prototype the new timetable view", done: false, who: [1, 2, 4] },
{ title: "Move fares data to the new service", done: false, who: [5, 2] },
];
const sizes = {
sm: { box: "h-8 w-8 text-[11px]", overlap: "-ml-2" },
md: { box: "h-12 w-12 text-sm", overlap: "-ml-3" },
lg: { box: "h-14 w-14 text-base sm:h-16 sm:w-16 sm:text-lg", overlap: "-ml-3 sm:-ml-4" },
};
export default function OverlappingAvatarStack() {
const [active, setActive] = useState<number | null>(null);
const [idle, setIdle] = useState(true);
// Until someone interacts, each avatar lifts in turn so the preview shows the tooltip.
useEffect(() => {
if (!idle || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let i = -1;
const tick = () => {
i = (i + 1) % people.length;
setActive(i);
};
const first = setTimeout(tick, 600);
const timer = setInterval(tick, 1500);
return () => {
clearTimeout(first);
clearInterval(timer);
};
}, [idle]);
const focusOn = (i: number | null) => {
setIdle(false);
setActive(i);
};
const size = sizes.lg;
return (
<section className="bg-slate-100 px-4 py-14 sm:px-6">
<div className="mx-auto max-w-2xl rounded-3xl bg-white p-5 shadow-sm ring-1 ring-slate-200 sm:p-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-sky-700">Project</p>
<h2 className="mt-1 text-2xl font-semibold tracking-tight text-slate-900">Harbour ferry app redesign</h2>
<p className="mt-1 text-sm text-slate-600">{people.length + EXTRA} people, 3 open tasks, due 28 November</p>
</div>
<button
type="button"
className="inline-flex h-11 items-center gap-2 rounded-full bg-slate-900 px-4 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2"
>
<UserPlus aria-hidden="true" className="h-4 w-4" />
Invite
</button>
</div>
<ul className="mt-20 flex pl-1" onPointerLeave={() => !idle && setActive(null)}>
{people.map((person, i) => {
const on = active === i;
return (
<li key={person.name} className={`relative ${i > 0 ? size.overlap : ""}`} style={{ zIndex: on ? 20 : people.length - i }}>
<button
type="button"
aria-label={`${person.name}, ${person.role}`}
onPointerEnter={() => focusOn(i)}
onFocus={() => focusOn(i)}
onBlur={() => setActive(null)}
onClick={() => focusOn(i)}
className={`flex items-center justify-center rounded-full font-semibold text-white ring-[3px] ring-white transition-transform duration-200 ease-out focus-visible:outline-none focus-visible:ring-slate-900 motion-reduce:transition-none ${size.box} ${person.tone} ${
on ? "-translate-y-2 scale-110" : ""
}`}
>
<span aria-hidden="true">{person.initials}</span>
</button>
{/* Tooltip. It is decoration for sighted pointer users: the same text is the button's label and the line below. */}
<span
aria-hidden="true"
className={`pointer-events-none absolute bottom-full mb-3 whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-left shadow-lg transition-all duration-200 ease-out motion-reduce:transition-none ${
on ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
} ${i === 0 ? "left-0" : "left-1/2 -translate-x-1/2"}`}
>
<span className="block text-sm font-semibold text-white">{person.name}</span>
<span className="block text-xs text-slate-300">{person.role}</span>
</span>
</li>
);
})}
<li className={`relative ${size.overlap}`} style={{ zIndex: 0 }}>
<span className={`flex items-center justify-center rounded-full bg-slate-200 font-semibold text-slate-700 ring-[3px] ring-white ${size.box}`}>
+{EXTRA}
<span className="sr-only"> more people</span>
</span>
</li>
</ul>
<p aria-live="polite" className="mt-4 min-h-[1.25rem] text-sm text-slate-600">
{active === null ? (
"Point at or tab to a face to see who it is."
) : (
<>
<span className="font-semibold text-slate-900">{people[active].name}</span>, {people[active].role.toLowerCase()}
</>
)}
</p>
<ul className="mt-6 divide-y divide-slate-200 border-t border-slate-200">
{tasks.map((task) => (
<li key={task.title} className="flex items-center gap-3 py-3.5">
{task.done ? <CheckCircle2 aria-hidden="true" className="h-5 w-5 shrink-0 text-emerald-600" /> : <Circle aria-hidden="true" className="h-5 w-5 shrink-0 text-slate-400" />}
<span className={`min-w-0 flex-1 truncate text-sm ${task.done ? "text-slate-500 line-through" : "font-medium text-slate-900"}`}>
{task.title}
<span className="sr-only">{task.done ? " (done)" : " (open)"}</span>
</span>
<span className="flex shrink-0" aria-label={`Assigned to ${task.who.map((w) => people[w].name).join(", ")}`} role="img">
{task.who.map((w, i) => (
<span
key={w}
aria-hidden="true"
className={`flex items-center justify-center rounded-full font-semibold text-white ring-2 ring-white ${sizes.sm.box} ${people[w].tone} ${i > 0 ? sizes.sm.overlap : ""}`}
>
{people[w].initials}
</span>
))}
</span>
</li>
))}
</ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/overlapping-avatar-stack-with-lift-and-name-tooltips.json
Every avatar after the first has a negative left margin, for example `-ml-4`, which pulls it over the one before. A white ring on each circle creates the cut-out look between them.
Each list item has a `z-index`. At rest the first person is highest so the row reads left over right, and the active one is raised above all of them so its whole circle and tooltip are visible.
A small idle demo lifts each one in turn so the tooltip can be seen without a mouse. It stops the first time someone hovers, focuses or taps an avatar. Delete the effect that uses `idle` to remove it.
Use buttons, as here, if pressing one does something on the page, and links if each goes to a profile. If they do nothing, render plain spans and keep one text alternative for the whole group, as the small task stacks do.
Four to six is a good limit. Past that, faces become hard to tell apart, so count the rest in the plus chip and link it to the full list.
Adapted from Avatar List in Animata, used under its open-source licence. Licences and credits
Related: Concentric Ripple Rings Background with Floating Avatars, Testimonial Cards with Star Ratings and Initials Avatars, Feature Request Voting Board with Live Reordering.
More cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.