Accessibility pattern · Data display
Avatar and presence
An avatar standing alone is the only place a name appears, so it carries the name; beside the name it is decoration, and announcing it again only doubles the noise. Presence is a word as well as a dot of a different shape, and the +4 in a stack of faces is a button that opens the full list, not a tooltip.
- WCAG criteria
- 6
- Keyboard rules
- 4
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
Website redesign
8 members · due 24 October
Team
Latest updates
-
Meera Iyer moved Home page hero to Review.
-
Kabir Mehta fixed the focus order in the sign-up form.
-
Don't:
alt="avatar"Heard as “avatar, image, Asha Rao”. It says what the picture is, not who.
-
Don't: the name twice
Heard as “Asha Rao, image, Asha Rao”, with
alt="Asha Rao"beside the name. -
Do:
alt=""beside the nameHeard as “Asha Rao”. Alone, as in the Team stack, it carries the name instead.
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab | Moves to your profile button, then the +4 button; when a panel is open, the next Tab goes into it. |
| Enter or Space | On the profile or +4 button, opens or closes its panel; focus stays on the button. |
| Arrow Down or Arrow Up | In Your status, chooses Online, Away or Do not disturb; the dot and the word under your name change together. |
| Escape | Closes the open panel and returns focus to the button that opened it. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| Focus reaches the profile button | Asha Rao Online, button, collapsed |
| Focus reaches the +4 button | +4 more members, button, collapsed |
| Reading the Team stack | Team, list, 4 items. Meera Iyer, image. Kabir Mehta, image. Priya Nair, image. Ravi Shah, image |
| Tab moves into the open member list | Meera Iyer Design lead Online, link |
| Away is chosen in Your status | Away, radio button, checked, 2 of 3 |
| Escape closes the panel | Asha Rao Away, button, collapsed |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<div class="ap-avatar" data-ap-avatar>
<div class="ap-avatar__top">
<div>
<h3 class="ap-avatar__name">Website redesign</h3>
<p class="ap-avatar__sub">8 members · due 24 October</p>
</div>
<div class="ap-avatar__anchor">
<button type="button" class="ap-avatar__me" aria-expanded="false" aria-controls="avatar-account" data-ap-toggle>
<span class="ap-avatar__pic ap-avatar__pic--sm" aria-hidden="true"><svg class="ap-avatar__art ap-avatar__art--info" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M10.5 27c1.7-2 2.2-5.4 2.2-10.6C12.7 10.3 15.9 6 20 6s7.3 4.3 7.3 10.4c0 5.2.5 8.6 2.2 10.6-2.6 1-5.8 1.5-9.5 1.5s-6.9-.5-9.5-1.5ZM5.5 40c1.2-7.6 6.8-11.2 14.5-11.2S33.3 32.4 34.5 40Z"/><circle class="ap-avatar__art-bg" cx="20" cy="17" r="5.4"/></svg><span class="ap-avatar__dot" data-presence="online" data-ap-me-dot></span></span>
<span class="ap-avatar__me-text"><span class="ap-avatar__me-name">Asha Rao</span> <span class="ap-avatar__me-status" data-ap-me-word>Online</span></span>
<svg class="ap-avatar__chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="ap-avatar__pop ap-avatar__pop--end" id="avatar-account" hidden>
<fieldset class="ap-avatar__status">
<legend class="ap-avatar__pop-name">Your status</legend>
<label class="ap-avatar__choice">
<input type="radio" name="avatar-status" value="online" checked />
<span aria-hidden="true" class="ap-avatar__dot" data-presence="online"></span>
<span>Online</span>
</label>
<label class="ap-avatar__choice">
<input type="radio" name="avatar-status" value="away" />
<span aria-hidden="true" class="ap-avatar__dot" data-presence="away"></span>
<span>Away</span>
</label>
<label class="ap-avatar__choice">
<input type="radio" name="avatar-status" value="busy" />
<span aria-hidden="true" class="ap-avatar__dot" data-presence="busy"></span>
<span>Do not disturb</span>
</label>
</fieldset>
</div>
</div>
</div>
<div class="ap-avatar__section">
<h4 class="ap-avatar__label" id="avatar-team">Team</h4>
<div class="ap-avatar__anchor ap-avatar__group">
<ul class="ap-avatar__stack" aria-labelledby="avatar-team">
<li><span class="ap-avatar__pic" role="img" aria-label="Meera Iyer"><svg class="ap-avatar__art ap-avatar__art--success" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M12.7 15.5c-.3-5.4 3-8.9 7.3-8.9s7.6 3.5 7.3 8.9c-.3 4.1-3.4 7.3-7.3 7.3s-7-3.2-7.3-7.3ZM5.5 40c1.2-8.4 6.8-12.6 14.5-12.6S33.3 31.6 34.5 40Z"/></svg></span></li>
<li><span class="ap-avatar__pic" role="img" aria-label="Kabir Mehta"><span class="ap-avatar__initials" data-ap-initials="Kabir Mehta">KM</span></span></li>
<li><span class="ap-avatar__pic" role="img" aria-label="Priya Nair"><svg class="ap-avatar__art ap-avatar__art--accent" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M10.5 27c1.7-2 2.2-5.4 2.2-10.6C12.7 10.3 15.9 6 20 6s7.3 4.3 7.3 10.4c0 5.2.5 8.6 2.2 10.6-2.6 1-5.8 1.5-9.5 1.5s-6.9-.5-9.5-1.5ZM5.5 40c1.2-7.6 6.8-11.2 14.5-11.2S33.3 32.4 34.5 40Z"/><circle class="ap-avatar__art-bg" cx="20" cy="17" r="5.4"/></svg></span></li>
<li><span class="ap-avatar__pic" role="img" aria-label="Ravi Shah"><span class="ap-avatar__initials" data-ap-initials="Ravi Shah">RS</span></span></li>
</ul>
<button type="button" class="ap-avatar__more" aria-label="+4 more members" aria-expanded="false" aria-controls="avatar-members" data-ap-toggle data-ap-more>+4</button>
<div class="ap-avatar__pop" id="avatar-members" hidden>
<p class="ap-avatar__pop-name" id="avatar-members-name">All 8 members</p>
<ul class="ap-avatar__people" aria-labelledby="avatar-members-name">
<li>
<a class="ap-avatar__person" href="#avatar-p-meera">
<span class="ap-avatar__pic" aria-hidden="true"><svg class="ap-avatar__art ap-avatar__art--success" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M12.7 15.5c-.3-5.4 3-8.9 7.3-8.9s7.6 3.5 7.3 8.9c-.3 4.1-3.4 7.3-7.3 7.3s-7-3.2-7.3-7.3ZM5.5 40c1.2-8.4 6.8-12.6 14.5-12.6S33.3 31.6 34.5 40Z"/></svg><span class="ap-avatar__dot" data-presence="online"></span></span>
<span class="ap-avatar__who"><span class="ap-avatar__pname">Meera Iyer</span> <span class="ap-avatar__role">Design lead</span></span>
<span class="ap-avatar__presence">Online</span>
</a>
</li>
<li>
<a class="ap-avatar__person" href="#avatar-p-kabir">
<span class="ap-avatar__pic" aria-hidden="true"><span class="ap-avatar__initials" data-ap-initials="Kabir Mehta">KM</span><span class="ap-avatar__dot" data-presence="away"></span></span>
<span class="ap-avatar__who"><span class="ap-avatar__pname">Kabir Mehta</span> <span class="ap-avatar__role">Developer</span></span>
<span class="ap-avatar__presence">Away</span>
</a>
</li>
<li>
<a class="ap-avatar__person" href="#avatar-p-priya">
<span class="ap-avatar__pic" aria-hidden="true"><svg class="ap-avatar__art ap-avatar__art--accent" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M10.5 27c1.7-2 2.2-5.4 2.2-10.6C12.7 10.3 15.9 6 20 6s7.3 4.3 7.3 10.4c0 5.2.5 8.6 2.2 10.6-2.6 1-5.8 1.5-9.5 1.5s-6.9-.5-9.5-1.5ZM5.5 40c1.2-7.6 6.8-11.2 14.5-11.2S33.3 32.4 34.5 40Z"/><circle class="ap-avatar__art-bg" cx="20" cy="17" r="5.4"/></svg><span class="ap-avatar__dot" data-presence="online"></span></span>
<span class="ap-avatar__who"><span class="ap-avatar__pname">Priya Nair</span> <span class="ap-avatar__role">Researcher</span></span>
<span class="ap-avatar__presence">Online</span>
</a>
</li>
<li>
<a class="ap-avatar__person" href="#avatar-p-ravi">
<span class="ap-avatar__pic" aria-hidden="true"><span class="ap-avatar__initials" data-ap-initials="Ravi Shah">RS</span><span class="ap-avatar__dot" data-presence="offline"></span></span>
<span class="ap-avatar__who"><span class="ap-avatar__pname">Ravi Shah</span> <span class="ap-avatar__role">Developer</span></span>
<span class="ap-avatar__presence">Offline</span>
</a>
</li>
<li>
<a class="ap-avatar__person" href="#avatar-p-asha">
<span class="ap-avatar__pic" aria-hidden="true"><svg class="ap-avatar__art ap-avatar__art--info" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M10.5 27c1.7-2 2.2-5.4 2.2-10.6C12.7 10.3 15.9 6 20 6s7.3 4.3 7.3 10.4c0 5.2.5 8.6 2.2 10.6-2.6 1-5.8 1.5-9.5 1.5s-6.9-.5-9.5-1.5ZM5.5 40c1.2-7.6 6.8-11.2 14.5-11.2S33.3 32.4 34.5 40Z"/><circle class="ap-avatar__art-bg" cx="20" cy="17" r="5.4"/></svg><span class="ap-avatar__dot" data-presence="online" data-ap-me-dot></span></span>
<span class="ap-avatar__who"><span class="ap-avatar__pname">Asha Rao</span> <span class="ap-avatar__role">Product manager · You</span></span>
<span class="ap-avatar__presence" data-ap-me-word>Online</span>
</a>
</li>
<li>
<a class="ap-avatar__person" href="#avatar-p-arjun">
<span class="ap-avatar__pic" aria-hidden="true"><span class="ap-avatar__initials" data-ap-initials="Arjun Das">AD</span><span class="ap-avatar__dot" data-presence="busy"></span></span>
<span class="ap-avatar__who"><span class="ap-avatar__pname">Arjun Das</span> <span class="ap-avatar__role">Content designer</span></span>
<span class="ap-avatar__presence">Do not disturb</span>
</a>
</li>
<li>
<a class="ap-avatar__person" href="#avatar-p-nisha">
<span class="ap-avatar__pic" aria-hidden="true"><span class="ap-avatar__initials" data-ap-initials="Nisha Kapoor">NK</span><span class="ap-avatar__dot" data-presence="offline"></span></span>
<span class="ap-avatar__who"><span class="ap-avatar__pname">Nisha Kapoor</span> <span class="ap-avatar__role">QA engineer</span></span>
<span class="ap-avatar__presence">Offline</span>
</a>
</li>
<li>
<a class="ap-avatar__person" href="#avatar-p-farhan">
<span class="ap-avatar__pic" aria-hidden="true"><svg class="ap-avatar__art ap-avatar__art--warning" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M12.7 15.5c-.3-5.4 3-8.9 7.3-8.9s7.6 3.5 7.3 8.9c-.3 4.1-3.4 7.3-7.3 7.3s-7-3.2-7.3-7.3ZM5.5 40c1.2-8.4 6.8-12.6 14.5-12.6S33.3 31.6 34.5 40Z"/></svg><span class="ap-avatar__dot" data-presence="online"></span></span>
<span class="ap-avatar__who"><span class="ap-avatar__pname">Farhan Ali</span> <span class="ap-avatar__role">Developer</span></span>
<span class="ap-avatar__presence">Online</span>
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="ap-avatar__section">
<h4 class="ap-avatar__label">Latest updates</h4>
<ul class="ap-avatar__updates">
<li>
<span class="ap-avatar__pic ap-avatar__pic--sm" aria-hidden="true"><svg class="ap-avatar__art ap-avatar__art--success" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M12.7 15.5c-.3-5.4 3-8.9 7.3-8.9s7.6 3.5 7.3 8.9c-.3 4.1-3.4 7.3-7.3 7.3s-7-3.2-7.3-7.3ZM5.5 40c1.2-8.4 6.8-12.6 14.5-12.6S33.3 31.6 34.5 40Z"/></svg></span>
<p><strong>Meera Iyer</strong> moved Home page hero to Review.</p>
<time class="ap-avatar__when" datetime="2026-10-05T09:50">10 min ago</time>
</li>
<li>
<span class="ap-avatar__pic ap-avatar__pic--sm" aria-hidden="true"><span class="ap-avatar__initials" data-ap-initials="Kabir Mehta">KM</span></span>
<p><strong>Kabir Mehta</strong> fixed the focus order in the sign-up form.</p>
<time class="ap-avatar__when" datetime="2026-10-05T08:15">2 h ago</time>
</li>
</ul>
</div>
<ul class="ap-avatar__compare" aria-label="One avatar beside a name, marked up three ways">
<li class="ap-avatar__case ap-avatar__case--dont">
<span class="ap-avatar__sample" aria-hidden="true"><span class="ap-avatar__pic ap-avatar__pic--sm"><svg class="ap-avatar__art ap-avatar__art--info" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M10.5 27c1.7-2 2.2-5.4 2.2-10.6C12.7 10.3 15.9 6 20 6s7.3 4.3 7.3 10.4c0 5.2.5 8.6 2.2 10.6-2.6 1-5.8 1.5-9.5 1.5s-6.9-.5-9.5-1.5ZM5.5 40c1.2-7.6 6.8-11.2 14.5-11.2S33.3 32.4 34.5 40Z"/><circle class="ap-avatar__art-bg" cx="20" cy="17" r="5.4"/></svg></span><span>Asha Rao</span></span>
<div>
<p class="ap-avatar__verdict">Don't: <code>alt="avatar"</code></p>
<p>Heard as “avatar, image, Asha Rao”. It says what the picture is, not who.</p>
</div>
</li>
<li class="ap-avatar__case ap-avatar__case--dont">
<span class="ap-avatar__sample" aria-hidden="true"><span class="ap-avatar__pic ap-avatar__pic--sm"><svg class="ap-avatar__art ap-avatar__art--info" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M10.5 27c1.7-2 2.2-5.4 2.2-10.6C12.7 10.3 15.9 6 20 6s7.3 4.3 7.3 10.4c0 5.2.5 8.6 2.2 10.6-2.6 1-5.8 1.5-9.5 1.5s-6.9-.5-9.5-1.5ZM5.5 40c1.2-7.6 6.8-11.2 14.5-11.2S33.3 32.4 34.5 40Z"/><circle class="ap-avatar__art-bg" cx="20" cy="17" r="5.4"/></svg></span><span>Asha Rao</span></span>
<div>
<p class="ap-avatar__verdict">Don't: the name twice</p>
<p>Heard as “Asha Rao, image, Asha Rao”, with <code>alt="Asha Rao"</code> beside the name.</p>
</div>
</li>
<li class="ap-avatar__case ap-avatar__case--do">
<span class="ap-avatar__sample" aria-hidden="true"><span class="ap-avatar__pic ap-avatar__pic--sm"><svg class="ap-avatar__art ap-avatar__art--info" viewBox="0 0 40 40" aria-hidden="true" focusable="false"><rect class="ap-avatar__art-bg" width="40" height="40"/><path class="ap-avatar__art-fig" d="M10.5 27c1.7-2 2.2-5.4 2.2-10.6C12.7 10.3 15.9 6 20 6s7.3 4.3 7.3 10.4c0 5.2.5 8.6 2.2 10.6-2.6 1-5.8 1.5-9.5 1.5s-6.9-.5-9.5-1.5ZM5.5 40c1.2-7.6 6.8-11.2 14.5-11.2S33.3 32.4 34.5 40Z"/><circle class="ap-avatar__art-bg" cx="20" cy="17" r="5.4"/></svg></span><span>Asha Rao</span></span>
<div>
<p class="ap-avatar__verdict">Do: <code>alt=""</code> beside the name</p>
<p>Heard as “Asha Rao”. Alone, as in the Team stack, it carries the name instead.</p>
</div>
</li>
</ul>
</div>
/* Shared primitives: buttons and form fields used across the patterns.
Uses the --ap-* design tokens. */
.ap-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
font-weight: 600;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease), border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-btn:hover {
background: var(--ap-surface-2);
}
.ap-btn:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-btn:disabled,
.ap-btn[aria-disabled="true"] {
opacity: .55;
cursor: not-allowed;
}
.ap-btn--primary {
border-color: var(--ap-accent);
background: var(--ap-accent);
color: var(--ap-on-accent);
box-shadow: var(--ap-shadow-sm);
}
.ap-btn--primary:hover {
border-color: var(--ap-accent-hover);
background: var(--ap-accent-hover);
}
.ap-btn--danger {
border-color: var(--ap-danger);
background: var(--ap-danger);
color: var(--ap-surface);
}
.ap-btn--danger:hover {
filter: brightness(1.08);
}
.ap-btn--ghost {
border-color: transparent;
background: transparent;
}
.ap-btn--ghost:hover {
background: var(--ap-surface-2);
}
.ap-btn__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-field {
display: grid;
gap: 6px;
}
.ap-label {
color: var(--ap-text);
font-weight: 600;
font-size: .9375rem;
}
.ap-hint {
margin: 0;
color: var(--ap-text-3);
font-size: .875rem;
line-height: 1.45;
}
.ap-error {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0;
color: var(--ap-danger);
font-size: .875rem;
font-weight: 600;
line-height: 1.45;
}
.ap-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-sm);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
line-height: 1.4;
transition: border-color var(--ap-duration) var(--ap-ease), box-shadow var(--ap-duration) var(--ap-ease);
}
.ap-input::placeholder {
color: var(--ap-text-3);
}
.ap-input:hover {
border-color: var(--ap-text-2);
}
.ap-input:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 1px;
border-color: var(--ap-focus);
}
.ap-input[aria-invalid="true"] {
border-color: var(--ap-danger);
box-shadow: inset 4px 0 0 var(--ap-danger);
}
textarea.ap-input {
resize: vertical;
min-height: 88px;
}
@media (prefers-reduced-motion: reduce) {
.ap-btn,
.ap-input { transition: none; }
}
/* Avatar and presence. Uses the --ap-* design tokens and the shared primitives. */
.ap-avatar {
width: min(100%, 580px);
margin-inline: auto;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
color: var(--ap-text);
box-shadow: var(--ap-shadow-md);
container-type: inline-size;
}
/* ── The avatar itself: a circle with a picture or initials, and an optional dot ── */
.ap-avatar__pic {
--ap-avatar-size: 40px;
position: relative;
display: inline-grid;
flex-shrink: 0;
width: var(--ap-avatar-size);
height: var(--ap-avatar-size);
border-radius: var(--ap-radius-full);
vertical-align: middle;
}
.ap-avatar__pic--sm {
--ap-avatar-size: 34px;
}
.ap-avatar__art,
.ap-avatar__initials {
grid-area: 1 / 1;
width: 100%;
height: 100%;
border-radius: var(--ap-radius-full);
}
.ap-avatar__art {
display: block;
overflow: hidden;
}
/* Tones: each is a token pair that clears 4.5:1, in both themes. */
.ap-avatar__art--accent, .ap-avatar__initials[data-tone="accent"], .ap-avatar__initials:not([data-tone]) { --ap-avatar-bg: var(--ap-accent-soft); --ap-avatar-fg: var(--ap-accent-soft-text); }
.ap-avatar__art--info, .ap-avatar__initials[data-tone="info"] { --ap-avatar-bg: var(--ap-info-soft); --ap-avatar-fg: var(--ap-info); }
.ap-avatar__art--success, .ap-avatar__initials[data-tone="success"] { --ap-avatar-bg: var(--ap-success-soft); --ap-avatar-fg: var(--ap-success); }
.ap-avatar__art--warning, .ap-avatar__initials[data-tone="warning"] { --ap-avatar-bg: var(--ap-warning-soft); --ap-avatar-fg: var(--ap-warning); }
.ap-avatar__art--danger, .ap-avatar__initials[data-tone="danger"] { --ap-avatar-bg: var(--ap-danger-soft); --ap-avatar-fg: var(--ap-danger); }
.ap-avatar__art-bg {
fill: var(--ap-avatar-bg);
}
.ap-avatar__art-fig {
fill: var(--ap-avatar-fg);
}
.ap-avatar__initials {
display: grid;
place-items: center;
background: var(--ap-avatar-bg);
color: var(--ap-avatar-fg);
font-size: calc(var(--ap-avatar-size) * .36);
font-weight: 700;
letter-spacing: .02em;
line-height: 1;
}
/* Presence: a shape as well as a color, always beside or inside a word. */
.ap-avatar__dot {
display: inline-block;
flex-shrink: 0;
width: 12px;
height: 12px;
border-radius: var(--ap-radius-full);
box-sizing: border-box;
}
.ap-avatar__pic > .ap-avatar__dot {
position: absolute;
right: -2px;
bottom: -2px;
box-shadow: 0 0 0 2px var(--ap-surface);
}
.ap-avatar__dot[data-presence="online"] {
background: var(--ap-success);
}
.ap-avatar__dot[data-presence="away"] {
border: 2px solid var(--ap-warning);
background: linear-gradient(90deg, var(--ap-warning) 50%, var(--ap-surface) 50%);
}
.ap-avatar__dot[data-presence="busy"] {
background: linear-gradient(var(--ap-surface) 0 0) center / 6px 2px no-repeat, var(--ap-danger);
}
.ap-avatar__dot[data-presence="offline"] {
border: 2px solid var(--ap-text-3);
background: var(--ap-surface);
}
/* ── Header and the profile button ── */
.ap-avatar__top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
padding: 16px 16px 16px 20px;
border-bottom: 1px solid var(--ap-border);
}
.ap-avatar__name {
margin: 0;
font-size: 1.0625rem;
font-weight: 650;
line-height: 1.3;
}
.ap-avatar__sub {
margin: 2px 0 0;
color: var(--ap-text-3);
font-size: .8125rem;
}
.ap-avatar__anchor {
position: relative;
}
.ap-avatar__me {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: var(--ap-target);
padding: 4px 10px 4px 5px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-full);
background: var(--ap-surface);
color: var(--ap-text);
font: inherit;
text-align: start;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease);
}
.ap-avatar__me:hover,
.ap-avatar__me[aria-expanded="true"] {
background: var(--ap-surface-2);
}
.ap-avatar__me:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-avatar__me-text {
display: grid;
line-height: 1.25;
}
.ap-avatar__me-name {
font-size: .875rem;
font-weight: 650;
}
.ap-avatar__me-status {
color: var(--ap-text-3);
font-size: .75rem;
}
.ap-avatar__chevron {
width: 16px;
height: 16px;
fill: none;
stroke: var(--ap-text-3);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform var(--ap-duration) var(--ap-ease);
}
.ap-avatar__me[aria-expanded="true"] .ap-avatar__chevron {
transform: rotate(180deg);
}
/* ── Panels: disclosures drawn as small floating cards ── */
.ap-avatar__pop {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 5;
width: min(320px, calc(100cqi - 32px));
padding: 12px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius);
background: var(--ap-surface);
box-shadow: var(--ap-shadow-lg);
animation: ap-avatar-in 160ms var(--ap-ease);
}
.ap-avatar__pop[hidden] {
display: none;
}
.ap-avatar__pop--end {
right: 0;
left: auto;
width: 220px;
}
.ap-avatar__pop-name {
margin: 0 0 6px;
padding: 0 4px;
color: var(--ap-text-3);
font-size: .75rem;
font-weight: 650;
letter-spacing: .04em;
text-transform: uppercase;
}
.ap-avatar__status {
display: grid;
gap: 2px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.ap-avatar__status legend {
float: left;
width: 100%;
}
.ap-avatar__choice {
position: relative;
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 0 10px;
border-radius: var(--ap-radius-sm);
font-size: .9375rem;
cursor: pointer;
}
.ap-avatar__choice:hover {
background: var(--ap-surface-2);
}
.ap-avatar__choice input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
appearance: none;
}
.ap-avatar__choice:has(input:checked) {
background: var(--ap-accent-soft);
color: var(--ap-accent-soft-text);
font-weight: 650;
}
.ap-avatar__choice:has(input:checked)::after {
content: "";
width: 6px;
height: 11px;
margin: -3px 2px 0 auto;
border: solid currentColor;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.ap-avatar__choice:has(input:focus-visible) {
outline: 2px solid var(--ap-focus);
outline-offset: -2px;
}
/* ── The team stack and its +4 button ── */
.ap-avatar__section {
padding: 16px 20px 18px;
}
.ap-avatar__section + .ap-avatar__section {
border-top: 1px solid var(--ap-border);
}
.ap-avatar__label {
margin: 0 0 10px;
color: var(--ap-text-2);
font-size: .8125rem;
font-weight: 650;
}
.ap-avatar__group {
display: flex;
align-items: center;
}
.ap-avatar__stack {
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.ap-avatar__stack li + li {
margin-inline-start: -8px;
}
.ap-avatar__stack .ap-avatar__pic,
.ap-avatar__more {
box-shadow: 0 0 0 2px var(--ap-surface);
}
.ap-avatar__more {
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin-inline-start: -8px;
padding: 0;
border: 1px solid var(--ap-border-strong);
border-radius: var(--ap-radius-full);
background: var(--ap-surface-2);
color: var(--ap-text);
font: inherit;
font-size: .8125rem;
font-weight: 700;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease);
}
.ap-avatar__more:hover,
.ap-avatar__more[aria-expanded="true"] {
background: var(--ap-surface-3);
}
.ap-avatar__more:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-avatar__people {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.ap-avatar__person {
display: flex;
align-items: center;
gap: 12px;
min-height: 48px;
padding: 6px 8px;
border-radius: var(--ap-radius-sm);
color: var(--ap-text);
text-decoration: none;
}
.ap-avatar__person:hover {
background: var(--ap-surface-2);
}
.ap-avatar__person:hover .ap-avatar__pname {
text-decoration: underline;
text-underline-offset: 3px;
}
.ap-avatar__person:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: -2px;
}
.ap-avatar__person .ap-avatar__pic {
--ap-avatar-size: 34px;
}
.ap-avatar__who {
display: grid;
flex: 1;
min-width: 0;
line-height: 1.3;
}
.ap-avatar__pname {
font-size: .9375rem;
font-weight: 600;
}
.ap-avatar__role {
color: var(--ap-text-3);
font-size: .8125rem;
}
.ap-avatar__presence {
flex-shrink: 0;
color: var(--ap-text-2);
font-size: .8125rem;
}
/* ── Latest updates: the name is right there, so the picture is decoration ── */
.ap-avatar__updates {
display: grid;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.ap-avatar__updates li {
display: flex;
align-items: flex-start;
gap: 12px;
}
.ap-avatar__updates p {
flex: 1;
margin: 6px 0 0;
color: var(--ap-text-2);
font-size: .9375rem;
line-height: 1.45;
}
.ap-avatar__updates strong {
color: var(--ap-text);
font-weight: 650;
}
.ap-avatar__when {
flex-shrink: 0;
margin-top: 8px;
color: var(--ap-text-3);
font-size: .8125rem;
}
/* ── Don't and do ── */
.ap-avatar__compare {
display: grid;
gap: 8px;
margin: 0;
padding: 16px 20px 20px;
border-top: 1px solid var(--ap-border);
border-radius: 0 0 var(--ap-radius-lg) var(--ap-radius-lg);
background: var(--ap-surface-2);
list-style: none;
}
.ap-avatar__case {
display: grid;
grid-template-columns: 128px minmax(0, 1fr);
align-items: center;
gap: 14px;
padding: 10px 14px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius);
background: var(--ap-surface);
}
.ap-avatar__case p {
margin: 0;
color: var(--ap-text-2);
font-size: .8125rem;
line-height: 1.45;
}
.ap-avatar__case .ap-avatar__verdict {
margin-bottom: 2px;
font-size: .875rem;
font-weight: 650;
}
.ap-avatar__case--dont .ap-avatar__verdict {
color: var(--ap-danger);
}
.ap-avatar__case--do .ap-avatar__verdict {
color: var(--ap-success);
}
.ap-avatar__case code {
font-family: var(--ap-mono);
font-size: .9em;
}
.ap-avatar__sample {
display: flex;
align-items: center;
gap: 8px;
font-size: .875rem;
font-weight: 600;
white-space: nowrap;
}
@keyframes ap-avatar-in {
from { opacity: 0; transform: translateY(-4px); }
}
@container (max-width: 460px) {
.ap-avatar__top,
.ap-avatar__section,
.ap-avatar__compare {
padding-inline: 16px;
}
.ap-avatar__case {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.ap-avatar__updates li {
flex-wrap: wrap;
}
.ap-avatar__when {
margin: -6px 0 0 46px;
flex-basis: 100%;
}
.ap-avatar__pop--end {
right: auto;
left: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ap-avatar__pop { animation: none; }
.ap-avatar__me,
.ap-avatar__more,
.ap-avatar__chevron { transition: none; }
}
@media (forced-colors: active) {
.ap-avatar__dot { forced-color-adjust: none; border: 2px solid CanvasText; }
.ap-avatar__dot[data-presence="online"] { background: CanvasText; }
.ap-avatar__dot[data-presence="away"] { background: linear-gradient(90deg, CanvasText 50%, Canvas 50%); }
.ap-avatar__dot[data-presence="busy"] { background: linear-gradient(Canvas 0 0) center / 6px 2px no-repeat, CanvasText; }
.ap-avatar__dot[data-presence="offline"] { background: Canvas; }
}
/**
* Avatar and presence: initials toned from the name, two disclosure panels
* (your status, all members) that close on Escape, outside clicks and focus
* leaving, and a status choice that updates your dot and its word together.
*
* Markup: [data-ap-avatar] holding button[data-ap-toggle][aria-controls]
* for each panel, [data-ap-initials="Full name"] initials, and radio inputs
* named avatar-status. Your own dot and word carry data-ap-me-dot and
* data-ap-me-word.
*/
// Five token pairs, each 4.5:1 or more in both themes (see style.css).
const TONES = ["accent", "info", "success", "warning", "danger"];
const WORDS = { online: "Online", away: "Away", busy: "Do not disturb" };
// The same name always gets the same tone.
function toneFor(name) {
let hash = 7;
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
return TONES[hash % TONES.length];
}
const initialsOf = (name) => name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join("");
export function init(root) {
const toggles = [...root.querySelectorAll("[data-ap-toggle]")];
const panelOf = (button) => document.getElementById(button.getAttribute("aria-controls"));
const outside = new AbortController();
for (const el of root.querySelectorAll("[data-ap-initials]")) {
const name = el.dataset.apInitials;
if (!el.textContent.trim()) el.textContent = initialsOf(name);
el.dataset.tone = toneFor(name);
}
function setOpen(button, open) {
button.setAttribute("aria-expanded", String(open));
panelOf(button).hidden = !open;
}
const openOne = () => toggles.find((b) => b.getAttribute("aria-expanded") === "true");
function onClick(event) {
const link = event.target.closest("a[href^='#avatar-']");
if (link) {
// The profile pages do not exist here; a real product follows the link.
event.preventDefault();
return;
}
const button = event.target.closest("[data-ap-toggle]");
if (!button) return;
const open = button.getAttribute("aria-expanded") !== "true";
for (const other of toggles) if (other !== button) setOpen(other, false);
setOpen(button, open);
}
function onKeydown(event) {
const button = openOne();
if (event.key !== "Escape" || !button) return;
const panel = panelOf(button);
if (!panel.contains(document.activeElement) && document.activeElement !== button) return;
event.preventDefault();
setOpen(button, false);
button.focus();
}
// A panel closes when focus moves somewhere outside it and its button.
function onFocusout(event) {
const button = openOne();
const next = event.relatedTarget;
if (!button || !next) return;
if (button.contains(next) || panelOf(button).contains(next)) return;
setOpen(button, false);
}
// Your status: the dot's shape and the word change together.
function onChange(event) {
if (event.target.name !== "avatar-status") return;
const value = event.target.value;
for (const dot of root.querySelectorAll("[data-ap-me-dot]")) dot.dataset.presence = value;
for (const word of root.querySelectorAll("[data-ap-me-word]")) word.textContent = WORDS[value];
}
document.addEventListener("pointerdown", (event) => {
const button = openOne();
if (button && !button.contains(event.target) && !panelOf(button).contains(event.target)) setOpen(button, false);
}, { signal: outside.signal });
root.addEventListener("click", onClick);
root.addEventListener("keydown", onKeydown);
root.addEventListener("focusout", onFocusout);
root.addEventListener("change", onChange);
return () => {
outside.abort();
root.removeEventListener("click", onClick);
root.removeEventListener("keydown", onKeydown);
root.removeEventListener("focusout", onFocusout);
root.removeEventListener("change", onChange);
};
}
for (const root of document.querySelectorAll("[data-ap-avatar]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.1.1 Non-text Content
Level A
A picture standing alone is named after the person; one beside the name is hidden, so the name is said once.
-
1.4.1 Use of Color
Level A
Presence is a word, and each dot has its own shape: filled for Online, half for Away, a bar for Do not disturb, a ring for Offline.
-
1.4.3 Contrast (Minimum)
Level AA
Initials sit on one of five token pairs that each clear 4.5:1 in light and dark, whichever tone a name gets.
-
1.4.11 Non-text Contrast
Level AA
Presence dots clear 3:1 against the surface, with a ring in the surface color that separates them from the picture.
-
2.5.3 Label in Name
Level A
The +4 button's name, +4 more members, starts with what it shows, so it can be said aloud to speech recognition.
-
4.1.2 Name, Role, Value
Level A
Both buttons expose aria-expanded and point at their panels; the status choice is a native radio group.
Usage
When to use it
Use it
- Showing who is on a team, who did something, or who is signed in, where a face or initials helps people scan.
- Presence in tools people use together: chat, documents, project boards.
Use something else
- The only way to find out who someone is: always show or offer the name too.
- Long member lists: a stack of faces is a summary; give the full list as text.
- Presence that changes every few seconds: announcing each change is noise; let people look it up.
Common failures
How it usually goes wrong
alt="avatar" or "profile picture"
It names the kind of image, not the person. People hear "avatar" over and over and learn nothing.
The name said twice
alt="Asha Rao" right beside the text Asha Rao doubles every row. Beside a visible name the image is decoration: alt="" or aria-hidden.
Presence as a colored dot
Green, amber and grey dots look alike to many people. Here each status has a word and its own shape.
Initials on a random hue
Generated colors give some people white text on yellow. Picking from a fixed set of passing token pairs keeps every name readable.
+4 that only shows a tooltip
Hovering to see who else is there leaves out keyboard and touch users. The +4 here is a button that opens the full list.
A profile button with no name
A round picture alone as the account button is announced as "button". The name sits in the button as text, with the picture hidden.
Notes
Building it
- With real photos the rules are the same: <img alt="Meera Iyer"> when the picture stands alone, alt="" when the name is beside it. The demo uses role=img with aria-label on drawn pictures, which works the same way.
- The script picks each initials tone from a hash of the name, so a person keeps their color; the five tones are token pairs, so contrast is checked once for all of them.
- Both panels are disclosures, not menus: they hold links and radio buttons, so Tab and the arrow keys work as usual, and they close when focus leaves them.
- Your own presence changes the dot and the word in one place each; other people's presence is read when someone looks, not announced as it changes.
Sources: W3C WAI Images Tutorial: Decorative images · WCAG 2.2 Understanding: Label in Name
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction