Display
Avatar stack
Overlapping avatars whose +n count is a button that lists everyone, with names and roles, in a popover.
- Built on
Popover APIpopovertarget:has()- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.1 kB
- Priya RamanOwner
- Tomás FerreiraPayments
- Ingrid HolmSecurity
- Kwame MensahPayments
- Yuki TanakaDesign
- Lena VogelLegal
- Omar HaddadSupport
Try its options
Loading its options.
Code
import { AvatarStack } from "@/components/equip/avatar-stack";
const reviewers = [
{ name: "Priya Raman", role: "Owner" },
{ name: "Tomás Ferreira", role: "Payments" },
{ name: "Ingrid Holm", role: "Security" },
{ name: "Kwame Mensah", role: "Payments" },
{ name: "Yuki Tanaka", role: "Design" },
{ name: "Lena Vogel", role: "Legal" },
{ name: "Omar Haddad", role: "Support" },
];
export default function Demo() {
return <AvatarStack label="Reviewers" people={reviewers} max={4} />;
}Your versionchange its options in Look, This part
Examples
Sizes
The size goes on the group and every avatar, the +n button included, follows it.
- Sofia LindqvistEngineering manager
- Arjun MehtaSite reliability
- Chen WeiBackend
- Fatima ZahraBackend
- Lucas MoreauProduct design
- Nia BrooksData
- Sofia LindqvistEngineering manager
- Arjun MehtaSite reliability
- Chen WeiBackend
- Fatima ZahraBackend
- Lucas MoreauProduct design
- Nia BrooksData
- Sofia LindqvistEngineering manager
- Arjun MehtaSite reliability
- Chen WeiBackend
- Fatima ZahraBackend
- Lucas MoreauProduct design
- Nia BrooksData
Code
import { AvatarStack } from "@/components/equip/avatar-stack";
const team = [
{ name: "Sofia Lindqvist", role: "Engineering manager" },
{ name: "Arjun Mehta", role: "Site reliability" },
{ name: "Chen Wei", role: "Backend" },
{ name: "Fatima Zahra", role: "Backend" },
{ name: "Lucas Moreau", role: "Product design" },
{ name: "Nia Brooks", role: "Data" },
];
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, justifyItems: "start" }}>
<AvatarStack size="s" label="Watching" people={team} max={3} />
<AvatarStack size="m" label="Watching" people={team} max={3} />
<AvatarStack size="l" label="Watching" people={team} max={3} />
</div>
);
}In a row with a caption
With only one person over max, all of them show: a +1 button would take the room of the avatar it hides.
4 people editing
- Ravi NairFinance
- Elif DemirFinance
- Hugo LaurentLegal
- Aiko MoriLegal
- Sam LeeOperations
- Dana OrtizOperations
- Olu AdeyemiSecurity
- Grace KimSecurity
Code
import { AvatarStack } from "@/components/equip/avatar-stack";
const editors = [
{ name: "Daniel Osei", role: "Author" },
{ name: "Ana Ruiz", role: "Editor" },
{ name: "Kenji Sato", role: "Reviewer" },
{ name: "Maya Okafor", role: "Reviewer" },
];
const approvers = [
{ name: "Ravi Nair", role: "Finance" },
{ name: "Elif Demir", role: "Finance" },
{ name: "Hugo Laurent", role: "Legal" },
{ name: "Aiko Mori", role: "Legal" },
{ name: "Sam Lee", role: "Operations" },
{ name: "Dana Ortiz", role: "Operations" },
{ name: "Olu Adeyemi", role: "Security" },
{ name: "Grace Kim", role: "Security" },
];
export default function Example() {
return (
<div style={{ display: "grid", gap: 16, fontSize: "var(--eq-text-s)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<AvatarStack size="s" label="Editing Q3 plan" people={editors} max={3} />
<span style={{ color: "var(--eq-ink-2)" }}>4 people editing</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<AvatarStack size="s" label="Approvers" people={approvers} max={3} />
<span style={{ color: "var(--eq-ink-2)" }}>8 approvers on the vendor contract</span>
</div>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
people | { name: string; role?: string; src?: string }[] | none | Everyone in the group, in display order. |
label | string | none | What the group is, such as Reviewers. The group is announced as Reviewers, 7 people. |
max | number | 4 | How many avatars show before the rest collapse into the +n button. |
size | "s" | "m" | "l" | "m" | Size of every avatar and of the +n button. |
...rest | ComponentProps<"div"> | none | Every native div attribute, applied to the group. |
Anatomy
| root | .eq-avatar-stack | A div with role="group". Owns the overlap and rings; data-size sizes the +n button and goes to each Avatar as its size. |
|---|---|---|
| avatar | .eq-avatar-stack-avatar | The className given to each visible Avatar, which draws its ring and overlap. |
| more | .eq-avatar-stack-more | The +n button, drawn like an avatar holding a count. |
| popover | .eq-avatar-stack-popover | The Popover holding the full list. |
| list | .eq-avatar-stack-list | Named like the group, such as Reviewers, 7 people. |
| person | .eq-avatar-stack-person | |
| text | .eq-avatar-stack-text | |
| name | .eq-avatar-stack-name | |
| role | .eq-avatar-stack-role |
Keyboard
| EnterSpace | On the +n button, opens or closes the list of everyone. |
|---|---|
| Escape | Closes the list and returns focus to the button if focus was inside. |
Notes
- The exported component is AvatarStack, because avatar already exports AvatarGroup; it uses Avatar only through its props, and draws its own overlap, rings and +n button.
- Avatar's AvatarGroup is static and server rendered, with a +n count that only says how many are hidden; AvatarStack takes people as data and makes the count a button that lists everyone.
- Each visible avatar is named with the person's role as well, such as Ingrid Holm, Security.
- The +n button is announced as 3 more. Show all 7, and is filled solid while its list is open.
- The open list is plain text named like the group, with nothing to focus, so it is a popover without a dialog role; screen readers reach it right after the button in reading order.
- When only one person is over max, every avatar shows, since a +1 button takes the room of the avatar it hides.
- It is a client part because it needs useId to connect the button to its list; many copies on one page do not collide.
- On a raised card, set --_ring on the stack to var(--eq-raised) so the gap between avatars matches the card.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/avatar-stack.tsx84 lines
"use client";
import { useId, type ComponentProps } from "react";
import { Avatar } from "./avatar";
import { Popover } from "./popover";
import "./avatar-stack.css";
export type AvatarStackPerson = {
name: string;
/** Shown under the name in the full list, and read with the name on its avatar. */
role?: string;
/** Image URL. Without it the avatar shows initials. */
src?: string;
};
export type AvatarStackProps = Omit<ComponentProps<"div">, "children"> & {
/** Everyone in the group, in the order they should appear. */
people: AvatarStackPerson[];
/** What the group is, e.g. "Reviewers". The group is announced as "Reviewers, 7 people". */
label: string;
/** How many avatars to show before the rest collapse into the +n button. */
max?: number;
size?: "s" | "m" | "l";
};
export function AvatarStack({ people, label, max = 4, size = "m", className, ...rest }: AvatarStackProps) {
const listId = `${useId()}-people`;
const total = people.length;
const limit = Math.max(0, Math.floor(max));
// Never collapse a single avatar: "+1" takes the same room as the avatar it hides.
const shown = total - limit === 1 ? people : people.slice(0, limit);
const hidden = total - shown.length;
const summary = `${label}, ${total} ${total === 1 ? "person" : "people"}`;
return (
<div
role="group"
aria-label={summary}
{...rest}
className={["eq-avatar-stack", className].filter(Boolean).join(" ")}
data-size={size}
>
{shown.map((person, index) => (
<Avatar
key={`${person.name}-${index}`}
className="eq-avatar-stack-avatar"
size={size}
name={person.name}
src={person.src}
alt={person.role ? `${person.name}, ${person.role}` : person.name}
/>
))}
{hidden > 0 && (
<>
<button
type="button"
className="eq-avatar-stack-more"
popoverTarget={listId}
aria-label={`${hidden} more. Show all ${total}`}
>
+{hidden}
</button>
<Popover
id={listId}
className="eq-avatar-stack-popover"
placement="bottom-start"
>
<ul className="eq-avatar-stack-list" aria-label={summary}>
{people.map((person, index) => (
<li className="eq-avatar-stack-person" key={`${person.name}-${index}`}>
<Avatar name={person.name} src={person.src} alt="" size="s" />
<span className="eq-avatar-stack-text">
<span className="eq-avatar-stack-name">{person.name}</span>
{person.role && <span className="eq-avatar-stack-role">{person.role}</span>}
</span>
</li>
))}
</ul>
</Popover>
</>
)}
</div>
);
}components/equip/avatar-stack.css119 lines
@layer equip {
/* The stack owns its overlap, rings and +n button; each Avatar only gets its size prop. */
.eq-avatar-stack {
/* The ring colour must match what sits behind the stack. Override it on a raised card. */
--_ring: var(--eq-surface);
--_size: var(--eq-h-m);
--_text: var(--eq-text-s);
display: inline-flex;
align-items: center;
}
.eq-avatar-stack[data-size="s"] {
--_size: var(--eq-h-s);
--_text: var(--eq-text-xs);
}
.eq-avatar-stack[data-size="l"] {
--_size: var(--eq-h-l);
--_text: var(--eq-text-l);
}
.eq-avatar-stack > :is(.eq-avatar-stack-avatar, .eq-avatar-stack-more) {
box-shadow: 0 0 0 2px var(--_ring);
}
.eq-avatar-stack > :is(.eq-avatar-stack-avatar, .eq-avatar-stack-more) + :is(.eq-avatar-stack-avatar, .eq-avatar-stack-more) {
margin-inline-start: calc(var(--_size) / -4);
}
/* The +n button, drawn like an avatar holding a count. */
.eq-avatar-stack > .eq-avatar-stack-more {
box-sizing: border-box;
position: relative;
display: inline-grid;
flex: none;
place-items: center;
width: var(--_size);
height: var(--_size);
margin-block: 0;
padding: 0;
border: 0;
border-radius: var(--eq-radius-full);
overflow: hidden;
/* Opaque, so it never shows the avatar it overlaps. */
background: color-mix(in srgb, var(--eq-ink) 9%, var(--eq-raised));
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--_text) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
vertical-align: middle;
user-select: none;
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-avatar-stack > .eq-avatar-stack-more:hover {
background: color-mix(in srgb, var(--eq-ink) 16%, var(--eq-raised));
}
}
.eq-avatar-stack > .eq-avatar-stack-more:active {
scale: var(--eq-press);
}
.eq-avatar-stack > .eq-avatar-stack-more:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* Solid while its list is open: the "on" state. */
.eq-avatar-stack > .eq-avatar-stack-more:has(+ .eq-avatar-stack-popover:popover-open) {
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
/* Popover's own padding is for prose; the list of people wants a tighter edge. */
.eq-popover.eq-avatar-stack-popover {
min-width: 200px;
padding: var(--eq-space-2);
}
.eq-avatar-stack-list {
display: grid;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.eq-avatar-stack-person {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-height: var(--eq-h-m);
padding-inline: var(--eq-space-1) var(--eq-space-2);
border-radius: var(--eq-radius-s);
}
.eq-avatar-stack-text {
display: grid;
min-width: 0;
font-size: var(--eq-text-s);
line-height: 1.3;
}
.eq-avatar-stack-name {
font-weight: var(--eq-weight-strong);
overflow-wrap: anywhere;
}
.eq-avatar-stack-role {
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
overflow-wrap: anywhere;
}
}