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
Built from
AvatarPopover
  • 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
8 approvers on the vendor contract
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

PropTypeDefaultWhat it does
people{ name: string; role?: string; src?: string }[]noneEveryone in the group, in display order.
labelstringnoneWhat the group is, such as Reviewers. The group is announced as Reviewers, 7 people.
maxnumber4How 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.
...restComponentProps<"div">noneEvery native div attribute, applied to the group.

Anatomy

root.eq-avatar-stackA 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-avatarThe className given to each visible Avatar, which draws its ring and overlap.
more.eq-avatar-stack-moreThe +n button, drawn like an avatar holding a count.
popover.eq-avatar-stack-popoverThe Popover holding the full list.
list.eq-avatar-stack-listNamed 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

EnterSpaceOn the +n button, opens or closes the list of everyone.
EscapeCloses 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index