Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/avatar-stack.jsonIt adds these packages for you:
clsxmotiontailwind-mergeThe source lands in your components folder as
avatar-stack.tsx. It's yours: change anything.
Usage
import { AvatarStack } from "@/components/avatar-stack"
export function Team() {
return (
<AvatarStack
label="People on this project"
people={[{ name: "Aarav Mehta", src: "/aarav.jpg" }, { name: "Lena Fischer" }]}
/>
)
}Source
One file, 276 lines, nothing hidden. Read it before you install it.
"use client";
import * as React from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
export interface AvatarPerson {
name: string;
/** Photo URL. Without one (or if it fails to load), initials are shown on a colour picked from the name. */
src?: string;
/** A second line in the tooltip, e.g. "Design lead". */
role?: string;
/** Makes the avatar a link, e.g. to a profile. */
href?: string;
/** A small dot on the avatar. */
status?: "online" | "away" | "busy";
}
export interface AvatarStackProps extends React.ComponentProps<"ul"> {
people: AvatarPerson[];
/** Show at most this many; "+N" lists the rest. */
max?: number;
/** Diameter in pixels. */
size?: number;
/** Accessible name of the group, e.g. "People on this project". */
label?: string;
/** Show a name tooltip that swings with the pointer. */
tooltips?: boolean;
/**
* Renders photos. Defaults to a plain `<img>`; pass `Image` from
* `next/image` (or your framework's) to get its optimisation.
*/
imageComponent?: React.ElementType;
}
// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_BOUNCY = { type: "spring", visualDuration: 0.45, bounce: 0.35 } as const;
const FOLLOW = { mass: 0.1, stiffness: 150, damping: 12 };
const INSTANT = { duration: 0 } as const;
const statusColor = { online: "bg-emerald-500", away: "bg-amber-500", busy: "bg-red-500" };
function hue(name: string) {
let h = 0;
for (const ch of name) h = (h * 31 + ch.codePointAt(0)!) % 360;
return h;
}
function initials(name: string) {
const parts = name.trim().split(/\s+/);
return ((parts[0]?.[0] ?? "") + (parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : "")).toUpperCase();
}
const describe = (person: AvatarPerson) => [person.name, person.role, person.status].filter(Boolean).join(", ");
function Face({ person, size, Img }: { person: AvatarPerson; size: number; Img: React.ElementType }) {
const [failed, setFailed] = React.useState(false);
const h = hue(person.name);
return person.src && !failed ? (
<Img
src={person.src}
alt={describe(person)}
width={size}
height={size}
loading="lazy"
onError={() => setFailed(true)}
className="size-full rounded-full object-cover"
/>
) : (
<span
role="img"
aria-label={describe(person)}
className="flex size-full items-center justify-center rounded-full font-medium"
style={{
background: `oklch(0.9 0.06 ${h})`,
color: `oklch(0.35 0.09 ${h})`,
fontSize: size * 0.38,
}}
>
<span aria-hidden="true">{initials(person.name)}</span>
</span>
);
}
function Status({ status, size }: { status?: AvatarPerson["status"]; size: number }) {
if (!status) return null;
const dot = Math.max(8, Math.round(size * 0.26));
return <span aria-hidden="true" className={cn("absolute bottom-0 right-0 rounded-full ring-2 ring-background", statusColor[status])} style={{ width: dot, height: dot }} />;
}
/**
* Overlapping avatars that spread when the pointer arrives. The person under
* it lifts, and a name tooltip pops above them, swinging with the pointer.
* "+N" opens a list of everyone else.
*
* - A list, with every person named (photo `alt` or initials' label,
* including their role and status); tooltips repeat that and are hidden
* from assistive technology.
* - People with an `href` are links, and get their tooltip on focus too.
* - "+N" is a button with `aria-expanded`; Escape or clicking away closes
* its list and Escape returns focus.
* - Broken photos fall back to initials on a colour derived from the name.
* - Under `prefers-reduced-motion` nothing swings or springs.
*/
export function AvatarStack({
people,
max = 5,
size = 36,
label = "People",
tooltips = true,
imageComponent = "img",
className,
style,
...props
}: AvatarStackProps) {
const id = React.useId();
const reduce = useReducedMotion();
const shown = people.slice(0, max);
const rest = people.slice(max);
const [active, setActive] = React.useState<number | null>(null);
const [open, setOpen] = React.useState(false);
const moreRef = React.useRef<HTMLLIElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
// The tooltip leans toward the pointer: offset within the avatar → a few degrees.
const pointer = useMotionValue(0);
const rotate = useSpring(useTransform(pointer, [-size / 2, size / 2], [-14, 14]), FOLLOW);
const shift = useSpring(useTransform(pointer, [-size / 2, size / 2], [-size * 0.2, size * 0.2]), FOLLOW);
React.useEffect(() => {
if (!open) return;
const doc = moreRef.current?.ownerDocument;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
buttonRef.current?.focus();
};
const onPointer = (event: PointerEvent) => {
if (!moreRef.current?.contains(event.target as Node)) setOpen(false);
};
doc?.addEventListener("keydown", onKey);
doc?.addEventListener("pointerdown", onPointer);
return () => {
doc?.removeEventListener("keydown", onKey);
doc?.removeEventListener("pointerdown", onPointer);
};
}, [open]);
const ring = "rounded-full ring-2 ring-background";
const overlap = "-ml-[calc(var(--avatar-size)*0.3)] group-hover/stack:-ml-[calc(var(--avatar-size)*0.12)]";
return (
<ul
aria-label={label}
className={cn("group/stack flex items-center", className)}
style={{ "--avatar-size": `${size}px`, ...style } as React.CSSProperties}
onPointerLeave={() => setActive(null)}
{...props}
>
{shown.map((person, i) => {
const face = (
<>
<Face person={person} size={size} Img={imageComponent} />
<Status status={person.status} size={size} />
</>
);
return (
<li
key={`${person.name}-${i}`}
onPointerEnter={() => setActive(i)}
onPointerMove={(event) => {
if (reduce) return;
const box = event.currentTarget.getBoundingClientRect();
pointer.set(event.clientX - box.left - box.width / 2);
}}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
className={cn(
"relative size-[var(--avatar-size)] shrink-0 transition-[margin,translate,scale] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
"animate-[ux-avatar-in_400ms_cubic-bezier(0.34,1.56,0.64,1)_both] motion-reduce:animate-none",
i > 0 && overlap,
active === i && "z-10 -translate-y-1 scale-110 motion-reduce:translate-y-0 motion-reduce:scale-100",
ring,
)}
style={{ animationDelay: `${i * 50}ms` }}
>
{person.href ? (
<a href={person.href} className="relative block size-full rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
{face}
</a>
) : (
face
)}
<AnimatePresence>
{tooltips && active === i && (
<motion.span
aria-hidden="true"
initial={{ opacity: 0, y: 6, scale: 0.85 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.9 }}
transition={reduce ? INSTANT : SPRING_BOUNCY}
style={reduce ? undefined : { rotate, x: shift }}
className="pointer-events-none absolute bottom-[calc(100%+10px)] left-1/2 z-20 -ml-24 flex w-48 justify-center"
>
<span className="whitespace-nowrap rounded-lg bg-foreground px-2.5 py-1.5 text-center text-xs text-background shadow-lg">
<span className="block font-medium">{person.name}</span>
{person.role && <span className="block opacity-70">{person.role}</span>}
</span>
</motion.span>
)}
</AnimatePresence>
</li>
);
})}
{rest.length > 0 && (
<li ref={moreRef} title={rest.map((p) => p.name).join(", ")} onPointerEnter={() => setActive(null)} className={cn("relative size-[var(--avatar-size)] shrink-0 transition-[margin] duration-300 motion-reduce:transition-none", overlap)}>
<button
ref={buttonRef}
type="button"
aria-expanded={open}
aria-controls={`${id}-more`}
onClick={() => setOpen((o) => !o)}
className={cn(
"flex size-full items-center justify-center bg-muted font-medium text-muted-foreground outline-none transition-colors duration-200 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
ring,
)}
style={{ fontSize: size * 0.34 }}
>
<span aria-hidden="true">+{rest.length}</span>
<span className="sr-only">and {rest.length} more</span>
</button>
<AnimatePresence>
{open && (
<motion.ul
id={`${id}-more`}
aria-label={`${rest.length} more`}
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.96 }}
transition={reduce ? INSTANT : SPRING_SNAPPY}
className="absolute left-1/2 top-[calc(100%+8px)] z-30 -ml-28 grid max-h-64 w-56 origin-top gap-0.5 overflow-y-auto rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-[0_20px_50px_-20px_rgb(0_0_0/0.45)]"
>
{rest.map((person, i) => (
<li key={`${person.name}-${i}`} className="flex items-center gap-2.5 rounded-lg px-2 py-1.5">
<span className="relative size-7 shrink-0">
<Face person={person} size={28} Img={imageComponent} />
<Status status={person.status} size={28} />
</span>
<span className="min-w-0 text-sm">
{person.href ? (
<a href={person.href} className="block truncate rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring">
{person.name}
</a>
) : (
<span aria-hidden="true" className="block truncate font-medium">
{person.name}
</span>
)}
{person.role && (
<span aria-hidden="true" className="block truncate text-xs text-muted-foreground">
{person.role}
</span>
)}
</span>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</li>
)}
</ul>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| people | { name; src?; role?; href?; status? }[] | — | Each person; role shows in the tooltip, href makes a link, status adds a dot. |
| max | number | 5 | Show at most this many; the rest become +N. |
| size | number | 36 | Diameter in pixels. |
| label | string | "People" | Accessible name of the list. |
| imageComponent | ElementType | "img" | Renders photos. Pass Image from next/image to get its optimisation. |
| tooltips | boolean | true | Name tooltip that swings with the pointer. |
Accessibility
- A labelled list; every person is named by their photo alt or initials label, including role and status.
- Tooltips repeat the name and are hidden from assistive technology; people with an href are links and show it on focus.
- +N is a button with aria-expanded; Escape or clicking away closes its list, and Escape returns focus.
- Under prefers-reduced-motion nothing swings or springs.
Changelog
- v1.0.0Latest
First release.