Composites
Letter Avatar
A person's photo when there is one, Vietnamese-style initials on a deterministic colour when there is not — plus AvatarGroup for stacking a team. The colour is a pure function of the name, so one person is one colour in every Comitor app and in every email those apps send.
Basic
There is no colour prop. Pass a name; the tone follows. Two people with the same tone is fine and expected — the colour is a memory aid, never an identifier.
Sizes
The stack overlap in AvatarGroup is per size, not one constant. The approved pattern is -space-x-2 on a 40px avatar — a 20% overlap. Applying that to xs (24px) would be a 33% overlap, and with the 2px ring on top the hidden edge reaches 10px while the first initial starts about 5.5px in: the letter disappears. Holding the ratio near 20% keeps every size readable at the density the design calls for.
| Size | Diameter | Group overlap |
|---|---|---|
xs | 24px | -space-x-1 · 4px · 17% |
sm | 28px | -space-x-1.5 · 6px · 21% |
md | 36px | -space-x-2 · 8px · 22% |
lg | 44px | -space-x-2.5 · 10px · 23% |
xl | 56px | -space-x-3 · 12px · 21% |
People are round, things are square
square swaps rounded-full for rounded-md. Shape is the only signal separating a person from a workspace or an app in a dense list, so keep the convention absolute.
Nguyễn Đức Thành
a person — round
Comitor Tasks
a workspace — square
Photo, with initials as the fallback
When src is set the image fills the avatar and the tone classes are not applied at all — a photo never picks up a tint. The image is a plain <img> with loading="lazy", not next/image: the . entry has to run under Vite, Storybook and test runners as well as Next, so optimisation is the app's job — pass a URL that already went through your CDN or loader.
The eight tones, and how a name picks one
getAvatarToneIndex(name) is an FNV-1a hash of the name — trimmed and lowercased with the vi-VN locale — modulo eight. It runs over code points rather than bytes so accented names hash identically on the server and in the browser. The hash function itself is not exported: handing it out only invites apps to build a second tone-picking scheme, which is precisely how one person ends up two colours.
bg-gold-100 text-gold-600 dark:bg-gold-500/30 dark:text-gold-100bg-teal/15 text-teal-inkbg-green/15 text-green-inkbg-red/15 text-red-inkbg-yellow/25 text-yellow-ink dark:bg-yellow/20bg-chart-3/15 text-chart-3bg-ash-100 text-ash-700 dark:bg-ash-500/25 dark:text-ash-100bg-ink/10 text-ink-soft dark:bg-paper/15 dark:text-paperThe palette is the accent set plus the chart ramp, not the semantic tokens. These are identity colours, not status colours — assigning --success to a human being would be a category error, and the accent and chart groups are the only tokens with eight distinct hues that also have a dark-mode counterpart.
Because the seed is normalised, the same person written three ways lands on the same tone:
index 2index 2index 2Classes for the DOM, hex for everything else
The tone table exists once, in a module neither entry point exports. It is reached through exactly two doors, and which one you use depends on whether Tailwind is present: getAvatarToneClasses(name) returns the class string for the DOM, and getAvatarToneColors(name, mode) in @comitor/ui/tokens returns opaque hex for canvas, PDF and email HTML. Both call the same getAvatarToneIndex, so web and email agree by construction rather than by discipline. The hex is pre-composited: most tones are alpha tints over --card, and there is nothing to composite against inside an email client, so the mixing is done ahead of time — which is why mode is a parameter.
| Name | light | dark |
|---|---|---|
| Phạm Minh Anh | PA #F8E89D | PA #342B14 |
| Nguyễn Văn An | NA #DDEDEE | NA #17292E |
| Nguyễn Đức Thành | NT #E0F2E9 | NT #1A2D29 |
| Vũ Thanh Hà | VH #F9E3E3 | VH #331F23 |
| Lê Văn Hùng | LH #FCF2D2 | LH #423B26 |
| Đỗ Quang Huy | ĐH #DBDDE2 | ĐH #262D3B |
| Trần Thị Mai | TM #EDEEF1 | TM #30333A |
| Đặng Tuấn Kiệt | ĐK #E7E7E8 | ĐK #38393C |
The swatches above are inline hex on purpose — they are literally what getAvatarToneColors hands a canvas or an email template, so both theme columns are shown side by side rather than following this page's theme. Inside a Tailwind app, never do this: use getAvatarToneClasses and let the tokens flip with the theme and with the high-contrast palette.
Overriding the tone
toneClassName is the supported way to colour an avatar by something other than its name — an app's accent, a workspace colour. Pass token classes, never a hex, or the avatar is pinned to one palette and wrong in the other.
Vietnamese initials
getVietnameseInitials takes the first character of the first word and the first character of the last word. Vietnamese names are family name first and given name last, and people are addressed by the given name — so the Western “first two words” rule would turn Nguyễn Đức Thành into NĐ, the two characters that identify him least. It is exported on its own, so a table cell or a mention chip can use the same rule without rendering an avatar.
"Nguyễn Đức Thành"NTfirst word + LAST word — Vietnamese people are addressed by the last name element"Trần Thị Bích Ngọc"TNmiddle words are skipped no matter how many there are"đặng văn lâm"ĐLuppercased with the vi-VN locale, so đ becomes Đ rather than D"Mai"Ma single word yields one character" "?blank input yields ? — never an empty coloured squareAvatarGroup
Assignees on a task, participants in a chat, followers on a deal. Every avatar is a focusable button that opens a tooltip with the full name and an optional caption — overlapping avatars hide part of the initials, and without the tooltip a stack is decoration that never says who is actually involved. Hover or Tab through the demo. The label prop names the group for screen readers and defaults to the Vietnamese Thành viên.
The +k chip. With onOverflowClick it renders as a button with a hover state and a focus ring; without it, as a plain span — which is right, since a chip that looks clickable and is not is worse than a static one. Its screen-reader text defaults to the Vietnamese Xem tất cả N thành viên and is not covered by label — it has a prop of its own. Pass overflowLabel={(count) => `See all ${count} members`}. It is a function rather than a template string because the number falls mid-sentence, and it receives items.length — the total — not the hidden remainder already printed on the chip face. Without onOverflowClick the chip is a plain span with no screen-reader text at all, so the prop only matters once the chip is a button.
surfaceClassName — why the group needs to know where it stands
Most avatar tones are alpha tints (bg-teal/15, bg-yellow/25), and a group deliberately overlaps them with -space-x-2. With no opaque layer underneath, the upper avatar's tint composites against the avatar below it instead of the page — three faces blend into one another exactly where they overlap. surfaceClassName supplies both halves of the fix: an opaque background under each avatar and the colour of the ring-2 that separates them. The ring alone is not enough — it draws around the avatar and cannot stop tints compositing inside it.
The default is bg-card ring-card, which is also the surface getAvatarToneColors assumes when it pre-composites its hex. Move the group to another surface and restate both classes.
default bg-card ring-card
on a bg-muted panel — rings and underlay are the wrong coloursurfaceClassName="bg-muted ring-muted"
matches the panel it stands onOne is a Server Component, one is not
LetterAvatar has no 'use client' directive — deliberately. Radix's Avatar primitive must be a client component because it tracks image load state; in a 200-row member table that is 200 pointless islands. This avatar is fully static and renders on the server. AvatarGroup is a client component, because the tooltip needs to be — so reach for it where the hover names earn the island.
// LetterAvatar has NO 'use client' — a 200-row table renders 200 avatars
// straight on the server. Radix Avatar is a client component because it tracks
// image load state; that would be 200 pointless islands here.
import { LetterAvatar } from '@comitor/ui'
export default async function MemberTable() {
const members = await db.members.findMany()
return (
<table>
<tbody>
{members.map((m) => (
<tr key={m.id}>
<td><LetterAvatar name={m.name} src={m.avatarUrl} size="sm" /></td>
<td>{m.name}</td>
</tr>
))}
</tbody>
</table>
)
}
// AvatarGroup IS 'use client' — it needs the tooltip. Keep it to the places
// where the hover names are worth the island.LetterAvatar props
| Prop | Type | Default | Description |
|---|---|---|---|
namerequired | string | — | Full display name. Drives the initials, the tone, the title attribute and the screen-reader text — one prop, four jobs. |
src | string | null | — | Photo URL. When present the image replaces the initials and the tone classes are not applied at all, so a photo is never tinted. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 24 / 28 / 36 / 44 / 56px, with the font size stepped to match. Note AvatarGroup defaults to sm, not md. |
square | boolean | false | rounded-md instead of rounded-full. Square means a thing — a workspace, an app, an organisation; round means a person. |
toneClassName | string | — | Replaces the name-derived tone with your own class pair, e.g. an app accent. Pass token classes (bg-teal/15 text-teal-ink), never a hex. |
className | string | — | Merged last, so it wins over the size and tone classes. |
...rest | Omit<React.ComponentProps<'span'>, 'children'> | — | Forwarded to the span. children is excluded on purpose — the content is computed from name. Spread last, so title can be overridden (AvatarGroup passes title={undefined} to stop the native tooltip fighting the Radix one). |
AvatarGroup props
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | AvatarGroupItem[] | — | Members to show, in order. { id, name, src?, caption? } — caption becomes the tooltip’s second line (email, role). |
max | number | 4 | How many avatars render before the rest collapse into a +k chip. Clamped at 0, so max={-1} collapses everything. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'sm' | Sets both the avatar size and the overlap, which is per-size rather than a single constant — see the sizes table. |
onOverflowClick | () => void | — | When provided the +k chip becomes a real button (hover state, focusable). Without it, the chip is a non-interactive span. |
overflowLabel | (count: number) => string | (count) => `Xem tất cả ${count} thành viên` | sr-only name of the +k chip, and only read out once onOverflowClick has made that chip a button. A function rather than a template string because the number falls mid-sentence, and it receives items.length — the total — not the remainder already printed on the chip face. |
label | string | 'Thành viên' | aria-label of the role="group" wrapper. Vietnamese by default — pass an English string such as “Assignees”. |
surfaceClassName | string | 'bg-card ring-card' | The surface the group is standing on: an opaque layer under each avatar plus the colour of the separating ring. Restate both classes whenever the group is not on a card. |
className | string | — | Merged onto the flex wrapper, after the overlap class. |
...rest | Omit<React.ComponentProps<'div'>, 'children'> | — | Forwarded to the wrapper div. children is excluded — the content comes from items. |
Items are typed as AvatarGroupItem: { id: string; name: string; src?: string | null; caption?: string }.
Accessibility
- The name is announced on every branch. The initials span is
aria-hidden— “NT” read aloud is noise — so asr-onlyspan carries the full name. Without it an avatar with no photo would be a mute coloured square, and insideAvatarGroupthat span is what gives each trigger button its accessible name. - The
<img>branch usesalt=""precisely because the samesr-onlyname is already there —alt={name}would read the person twice. title={name}gives a native hover tooltip for sighted mouse users.AvatarGrouppassestitle={undefined}to suppress it there, so the native tooltip does not race the Radix one.- The group wrapper is
role="group"witharia-labelfromlabel, so the stack is announced as one named region rather than a run of loose images. - Each avatar in a group is a real
<button type="button">with no click handler. That is intentional: a bare<span>receives mouse events only, so the tooltip — and therefore the member's name — would be invisible to keyboard users. - The focus ring is
outline-foreground, notring.--ringis the brand gold, and #E8B824 is only 1.86:1 against the page in light mode — under the 3:1 that WCAG 1.4.11 / 2.4.11 require of a focus indicator (in dark, #F5D060 reaches 12.67:1, so the naive choice fails for exactly half the users).outline-foregroundmeasures 18.91:1 light and 17.66:1 dark, matching the row focus ring inDataTable. - Initials clear AA even though they are aria-hidden — sighted users still read them. The first pass put
text-teal/green/redstraight on a 15% tint and failed in light mode: teal 3.44:1, green 2.69:1 (below even 3:1), red 3.57:1. Fixing it followed the Badge rule — background token for the fill,-inktoken for the glyph. Measured on the exact composited hex:#1A6668on#DDEDEE= 5.54:1,#216A48on#E0F2E9= 5.60:1,#9A3537on#F9E3E3= 5.83:1, with 7.12 / 7.44 / 5.18:1 for the dark counterparts. - Tooltip captions run at
opacity-80, and that is measured rather than careless: the tooltip isbg-foreground text-background, so 80% still yields 12.15:1 in light and 9.85:1 in dark. Tooltip content is also non-interactive. - Colour is never the only carrier of identity — the initials and the accessible name always accompany it, and eight tones over any real directory means collisions are routine. Do not use tone to encode state.
- Vietnamese defaults to replace in an English app:
label(“Thành viên”) andoverflowLabel(“Xem tất cả N thành viên”, the +k chip'ssr-onlyname). Translatinglabelalone leaves the last button in the group speaking Vietnamese.