Display
Avatar
A person's or team's picture, or their initials when there is none. Groups overlap with a +n count.
- Built on
<img>:has()- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.9 kB
- Dependencies
- React
+3
Try its options
Loading its options.
Code
import { Avatar, AvatarGroup } from "@/components/equip/avatar";
export default function Demo() {
return (
<AvatarGroup max={4} aria-label="Reviewers">
<Avatar name="Priya Raman" />
<Avatar name="Tomás Ferreira" />
<Avatar name="Ingrid Holm" />
<Avatar name="Kwame Mensah" />
<Avatar name="Yuki Tanaka" />
<Avatar name="Lena Vogel" />
<Avatar name="Omar Haddad" />
</AvatarGroup>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div role="group" aria-label="Reviewers" class="eq-avatar-group" data-size="m">
<span role="img" aria-label="Priya Raman" class="eq-avatar" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">PR</span>
</span>
<span role="img" aria-label="Tomás Ferreira" class="eq-avatar" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">TF</span>
</span>
<span role="img" aria-label="Ingrid Holm" class="eq-avatar" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">IH</span>
</span>
<span role="img" aria-label="Kwame Mensah" class="eq-avatar" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">KM</span>
</span>
<span class="eq-avatar-overflow" role="img" aria-label="3 more">+3</span>
</div>Examples
Sizes and shapes
Sizes match the s, m and l control heights, so an avatar lines up with a button beside it.
Code
import { Avatar } from "@/components/equip/avatar";
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, justifyItems: "start" }}>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Avatar name="Maya Okafor" size="s" />
<Avatar name="Maya Okafor" size="m" />
<Avatar name="Maya Okafor" size="l" />
</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Avatar name="Platform team" shape="square" size="s" />
<Avatar name="Platform team" shape="square" size="m" />
<Avatar name="Platform team" shape="square" size="l" />
</div>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;justify-items:start">
<div style="display:flex;gap:12px;align-items:center">
<span role="img" aria-label="Maya Okafor" class="eq-avatar" data-size="s" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">MO</span>
</span>
<span role="img" aria-label="Maya Okafor" class="eq-avatar" data-size="m" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">MO</span>
</span>
<span role="img" aria-label="Maya Okafor" class="eq-avatar" data-size="l" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">MO</span>
</span>
</div>
<div style="display:flex;gap:12px;align-items:center">
<span role="img" aria-label="Platform team" class="eq-avatar" data-size="s" data-shape="square">
<span class="eq-avatar-initials" aria-hidden="true">PT</span>
</span>
<span role="img" aria-label="Platform team" class="eq-avatar" data-size="m" data-shape="square">
<span class="eq-avatar-initials" aria-hidden="true">PT</span>
</span>
<span role="img" aria-label="Platform team" class="eq-avatar" data-size="l" data-shape="square">
<span class="eq-avatar-initials" aria-hidden="true">PT</span>
</span>
</div>
</div>Beside a name
With the name printed next to it, alt="" hides the avatar from screen readers so the name is read once.
Daniel OseiApproved the merge request
Code
import { Avatar } from "@/components/equip/avatar";
export default function Example() {
return (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Avatar name="Daniel Osei" alt="" />
<div style={{ display: "grid", gap: 2, fontSize: 13 }}>
<span style={{ fontWeight: 650 }}>Daniel Osei</span>
<span style={{ color: "var(--eq-ink-2)" }}>Approved the merge request</span>
</div>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;align-items:center;gap:10px">
<span aria-hidden="true" class="eq-avatar" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">DO</span>
</span>
<div style="display:grid;gap:2px;font-size:13px">
<span style="font-weight:650">Daniel Osei</span>
<span style="color:var(--eq-ink-2)">Approved the merge request</span>
</div>
</div>Group sizes
The group sets the size of every avatar inside it, and the overflow count is announced as "3 more".
+3
Code
import { Avatar, AvatarGroup } from "@/components/equip/avatar";
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, justifyItems: "start" }}>
<AvatarGroup size="s" max={3} aria-label="Watching this incident">
<Avatar name="Sofia Lindqvist" />
<Avatar name="Arjun Mehta" />
<Avatar name="Chen Wei" />
<Avatar name="Fatima Zahra" />
<Avatar name="Lucas Moreau" />
<Avatar name="Nia Brooks" />
</AvatarGroup>
<AvatarGroup size="l" aria-label="Workspaces">
<Avatar name="Design systems" shape="square" />
<Avatar name="Growth" shape="square" />
<Avatar name="Infra on call" shape="square" />
</AvatarGroup>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;justify-items:start">
<div role="group" aria-label="Watching this incident" class="eq-avatar-group" data-size="s">
<span role="img" aria-label="Sofia Lindqvist" class="eq-avatar" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">SL</span>
</span>
<span role="img" aria-label="Arjun Mehta" class="eq-avatar" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">AM</span>
</span>
<span role="img" aria-label="Chen Wei" class="eq-avatar" data-shape="round">
<span class="eq-avatar-initials" aria-hidden="true">CW</span>
</span>
<span class="eq-avatar-overflow" role="img" aria-label="3 more">+3</span>
</div>
<div role="group" aria-label="Workspaces" class="eq-avatar-group" data-size="l">
<span role="img" aria-label="Design systems" class="eq-avatar" data-shape="square">
<span class="eq-avatar-initials" aria-hidden="true">DS</span>
</span>
<span role="img" aria-label="Growth" class="eq-avatar" data-shape="square">
<span class="eq-avatar-initials" aria-hidden="true">G</span>
</span>
<span role="img" aria-label="Infra on call" class="eq-avatar" data-shape="square">
<span class="eq-avatar-initials" aria-hidden="true">IC</span>
</span>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
name | string | none | Who it is. Gives the alt text and the initials. |
src | string | none | Image URL. Without it the avatar shows initials. |
alt | string | name | Text alternative. Pass an empty string when the name is already written next to the avatar. |
size | "s" | "m" | "l" | "m" | 28, 34 or 42px, matching control heights. Leave unset inside a group to follow it. |
shape | "round" | "square" | "round" | Square is a rounded square, for teams and workspaces. |
...rest | ComponentProps<"span"> | none | Every native span attribute. |
AvatarGroup max | number | none | How many avatars to show before the rest collapse into a +n count. |
AvatarGroup size | "s" | "m" | "l" | "m" | Size for every avatar in the group that does not set its own. |
AvatarGroup ...rest | ComponentProps<"div"> | none | Every native div attribute, including aria-label. |
Anatomy
| root | .eq-avatar | A span. Carries role="img" and the name when it shows initials. |
|---|---|---|
| image | .eq-avatar-image | |
| initials | .eq-avatar-initials | Only without src. Hidden from assistive tech; the root carries the name. |
| group | .eq-avatar-group | A div with role="group". |
| overflow | .eq-avatar-overflow | The +n count, announced as "n more". |
Notes
- With an image, the name is the alt text; with initials, the root is role="img" with the name as its label, so both read the same.
- Pass alt="" when the name is printed beside the avatar, so screen readers do not hear it twice.
- There is no automatic fallback when an image fails to load, because that needs an error handler on the client; pass src only for images you know exist.
- Give an AvatarGroup an aria-label such as "Reviewers" so the group has a name.
- On a raised card, set --_ring on the group 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.tsx85 lines
import { Children, type ComponentProps } from "react";
import "./avatar.css";
export type AvatarProps = ComponentProps<"span"> & {
/** The person or team. Used for the alt text and the initials. */
name: string;
/** Image URL. Without it the avatar shows initials from `name`. */
src?: string;
/** Text alternative. Defaults to `name`; pass "" when the name is already written beside it. */
alt?: string;
/** Matches the control heights. Leave unset inside AvatarGroup to follow the group. */
size?: "s" | "m" | "l";
shape?: "round" | "square";
};
/** "Ana María Ortiz" gives "AO", "Ravi" gives "R", "" gives "". */
function initials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
const first = words[0];
if (first === undefined) return "";
const last = words.length > 1 ? words[words.length - 1] : undefined;
const lead = Array.from(first)[0] ?? "";
const tail = last === undefined ? "" : (Array.from(last)[0] ?? "");
return (lead + tail).toLocaleUpperCase();
}
export function Avatar({
name,
src,
alt = name,
size,
shape = "round",
className,
...rest
}: AvatarProps) {
const decorative = alt === "";
return (
<span
role={src || decorative ? undefined : "img"}
aria-label={src || decorative ? undefined : alt}
aria-hidden={decorative || undefined}
{...rest}
className={["eq-avatar", className].filter(Boolean).join(" ")}
data-size={size}
data-shape={shape}
>
{src ? (
<img className="eq-avatar-image" src={src} alt={alt} />
) : (
<span className="eq-avatar-initials" aria-hidden="true">
{initials(name)}
</span>
)}
</span>
);
}
export type AvatarGroupProps = ComponentProps<"div"> & {
/** How many avatars to show before the rest collapse into a "+n" count. */
max?: number;
/** Size for every avatar in the group that does not set its own. */
size?: "s" | "m" | "l";
};
export function AvatarGroup({ max, size = "m", className, children, ...rest }: AvatarGroupProps) {
const items = Children.toArray(children);
const limit = max === undefined ? items.length : Math.max(0, Math.floor(max));
const shown = items.slice(0, limit);
const hidden = items.length - shown.length;
return (
<div
role="group"
{...rest}
className={["eq-avatar-group", className].filter(Boolean).join(" ")}
data-size={size}
>
{shown}
{hidden > 0 && (
<span className="eq-avatar-overflow" role="img" aria-label={`${hidden} more`}>
+{hidden}
</span>
)}
</div>
);
}components/equip/avatar.css91 lines
@layer equip {
/* Size travels as inherited variables, so an AvatarGroup can size the avatars inside it. */
:is(.eq-avatar, .eq-avatar-group)[data-size="s"] {
--_avatar-size: var(--eq-h-s);
--_avatar-text: var(--eq-text-xs);
--_avatar-corner: var(--eq-radius-s);
}
:is(.eq-avatar, .eq-avatar-group)[data-size="m"] {
--_avatar-size: var(--eq-h-m);
--_avatar-text: var(--eq-text-s);
--_avatar-corner: var(--eq-radius-m);
}
:is(.eq-avatar, .eq-avatar-group)[data-size="l"] {
--_avatar-size: var(--eq-h-l);
--_avatar-text: var(--eq-text-l);
--_avatar-corner: var(--eq-radius-m);
}
.eq-avatar,
.eq-avatar-overflow {
box-sizing: border-box;
position: relative;
display: inline-grid;
flex: none;
place-items: center;
width: var(--_avatar-size, var(--eq-h-m));
height: var(--_avatar-size, var(--eq-h-m));
border-radius: var(--eq-radius-full);
overflow: hidden;
/* Opaque, so overlapping avatars never show through each other. */
background: color-mix(in srgb, var(--eq-ink) 9%, var(--eq-raised));
color: var(--eq-ink-2);
font: var(--eq-weight-strong) var(--_avatar-text, var(--eq-text-s)) / 1 var(--eq-font);
letter-spacing: 0.02em;
vertical-align: middle;
user-select: none;
}
.eq-avatar[data-shape="square"] {
border-radius: var(--_avatar-corner, var(--eq-radius-m));
}
/* A hairline drawn over the image, so a pale photo still has an edge. */
.eq-avatar::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 0 1px var(--eq-line);
pointer-events: none;
}
.eq-avatar-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.eq-avatar-initials {
line-height: 1;
}
.eq-avatar-group {
/* The ring colour must match what sits behind the group. Override it on a raised card. */
--_ring: var(--eq-surface);
display: inline-flex;
align-items: center;
}
.eq-avatar-group > :is(.eq-avatar, .eq-avatar-overflow) {
box-shadow: 0 0 0 2px var(--_ring);
}
.eq-avatar-group > :is(.eq-avatar, .eq-avatar-overflow) + :is(.eq-avatar, .eq-avatar-overflow) {
margin-inline-start: calc(var(--_avatar-size, var(--eq-h-m)) / -4);
}
.eq-avatar-group:has(> .eq-avatar[data-shape="square"]) > .eq-avatar-overflow {
border-radius: var(--_avatar-corner, var(--eq-radius-m));
}
.eq-avatar-overflow {
color: var(--eq-ink);
letter-spacing: 0;
font-variant-numeric: tabular-nums;
}
}