Composites
Icon Avatar
An app or workspace icon in a coloured identity tile — the sibling of Letter Avatar. The colour is a pure function of the identity, so a new product has a colour the moment it exists — the same colour in every Comitor app, and in the emails those apps send.
Basic
There is no colour prop. Pass a name, an icon and the app's key as seed; the tone follows. It replaces the shape it came from — a hardcoded table of a few accent tokens, where the sixth product either matched another or waited for someone to edit the table.
Seed on the key, not the name
LetterAvatar hashes the name, and that is right for a person: a person's name does not change with the interface language. An app's does — and that breaks the one promise the colour makes, that the same app is always the same colour. Measured on the real Comitor catalogue, hashing the translated name flips 4 of 6 apps to a different colour when the user switches vi ⇄ en; hashing the stable key flips 0 of 6. So wherever there is an invariant identifier, pass it as seed: the key for an app, the slug for a workspace. name stays the string a screen reader reads.
index 3 · tone red
Type tasks then Việc: the tone jumps, because they hash to different buckets. Two names for one app, two colours — exactly what seed prevents.
import { IconAvatar } from '@comitor/ui'
import { SquareCheckBig } from 'lucide-react'
// seed is the STABLE key; name is the label a screen reader reads out.
<IconAvatar name="Tasks" seed="tasks" icon={SquareCheckBig} />
// WRONG for an app — with no seed the tone is hashed from the display name,
// which is translated, so the colour changes with the interface language.
// Measured across the Comitor catalogue: 4 of 6 apps flip colour on vi <-> en.
<IconAvatar name="Việc" icon={SquareCheckBig} /> // hashes "Việc"
<IconAvatar name="Tasks" icon={SquareCheckBig} /> // hashes "Tasks" — a different tone
// A workspace seeds on its slug for the same reason. A person is the exception:
// their name does not change with language, so LetterAvatar seeds on the name.Sizes
| Size | Tile | Glyph |
|---|---|---|
xs | 24px | 14px |
sm | 28px | 16px |
md | 36px | 16px |
lg | 44px | 20px |
xl | 56px | 24px |
Square is the default, because a thing is not a person
Round is the shape of a person; a rounded square is the shape of a thing — an app, a workspace, a project — the same convention LetterAvatar square uses for a workspace. So IconAvatar defaults to square, the inverse of LetterAvatar, and rounded="full" reopens the round branch for the rare place that needs it.
Tasks
an app — square (default)
rounded="full"
the round branch
An uploaded logo wins over the icon
When src is set it fills the tile and the tone classes are not applied at all — a real logo is never tinted. icon is the fallback, not the other way round, and a tile with neither still renders (an empty coloured square) rather than throwing. As on LetterAvatar, the image is a plain <img> with loading="lazy", not next/image — pass a URL that already went through your loader.
The eight tones, and choosing one by hand
The colour comes from the same eight-tone table and the same hash as LetterAvatar — the Letter Avatar page documents both, and the hex door getAvatarToneColors that carries the same colour into an email or a PDF. When the derived colour is not the one you want, tone picks one of the eight names by hand. AVATAR_TONE_NAMES is that set, in order.
goldtealgreenredyellownavyashneutralA name outside the set does not throw — it falls back to the seed-derived colour. That is a deliberate choice, not laxity: the value usually arrives from a database column, where a row can outlive the tone it named, and taking a whole catalogue of apps down over one stale string is the worse failure. The raw table and the hash stay unexported for the same reason they do on LetterAvatar — handing them out invites a second colour scheme, which is how one identity ends up two colours.
Eight buckets, so a real catalogue collides
Eight tones over a catalogue of six or more means two apps will share one — the arithmetic of hashing into eight buckets, and no different hash fixes it. The table below is computed live from the real keys: starter and mes both land on yellow, tasks and analytics both on red. Where that matters, tone is the way out — set it on one of the pair.
| App | seed | index | tone | tile |
|---|---|---|---|---|
| Tasks | tasks | 3 | red | Tasks |
| Analytics | analytics | 3 | red | Analytics |
| CRM | crm | 1 | teal | CRM |
| Calendar | calendar | 1 | teal | Calendar |
| Starter | starter | 4 | yellow | Starter |
| Messages | mes | 4 | yellow | Messages |
| Helpdesk | helpdesk | 5 | navy | Helpdesk |
| HR | hr | 7 | neutral | HR |
The glyph is always a solid colour
A tone's background is an alpha tint (bg-teal/15), but the glyph is always a solid ink step (text-teal-ink), and that is not incidental. A Lucide icon is several strokes, and where two semi-transparent strokes cross the join is painted twice and shows a darker seam — the same defect the sidebar icons carried before 1.8.2. The tone table is already correct for this, so never add opacity-* or a /NN to the glyph.
Sibling of Letter Avatar, and a Server Component
The two share the whole colour layer and differ in one thing: what sits in the middle of the tile. An initial means a person, so LetterAvatar takes a name and rounds it; an icon means a thing, so IconAvatar takes an icon and squares it. Splitting them keeps each with one clear shape rather than overloading name to mean both the initials source and the colour seed. Like its sibling, IconAvatar carries no 'use client' — a launcher grid renders its tiles straight on the server.
// IconAvatar has NO 'use client' — a launcher grid or an app list renders its
// tiles straight on the server, the same as LetterAvatar. The colour is a pure
// function of the seed, so there is no state and no island to pay for.
import { IconAvatar } from '@comitor/ui'
export default async function AppGrid() {
const apps = await db.apps.findMany()
return (
<ul className="grid grid-cols-3 gap-2">
{apps.map((app) => (
<li key={app.id}>
<IconAvatar name={app.name} seed={app.id} icon={ICONS[app.id]} tone={app.iconColor} />
</li>
))}
</ul>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
namerequired | string | — | The accessible name of the tile (an sr-only span and the title tooltip), and the default colour seed when seed is not given. |
seed | string | — | The colour seed when it differs from the display name. For an app pass the key ("tasks"), for a workspace the slug — an invariant identifier, because name changes with the interface language and the colour would move with it. |
icon | ComponentType<{ className?: string }> | — | The glyph in the middle — a Lucide icon or any component taking className, never an already-rendered element, so the size flows from the size prop rather than the call site. |
src | string | null | — | An uploaded image. When present it fills the tile and wins over icon, and the tone classes are dropped, so a real logo is never tinted. |
tone | AvatarToneName | string | null | — | Pick one of the eight tone names by hand instead of deriving it. An unknown or empty value falls back to the seed-derived colour rather than throwing. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 24 / 28 / 36 / 44 / 56px tiles, with the glyph stepped to match. Same scale as LetterAvatar. |
rounded | 'md' | 'full' | 'md' | md is a rounded square — a thing. full is a circle — a person. The default is square, the inverse of LetterAvatar. |
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 icon, src or nothing. |
Accessibility
- The name is always announced. The tile carries a
sr-onlyspan withnameon every branch — icon, image or empty — so it is never a mute coloured square, andtitleis set tonamefor a native hover tooltip. - The glyph is decoration: the meaning is the name, which is why the icon is not given a label of its own, and the
<img>branch usesalt=""so the name is not read twice. - The glyph runs on the
-inkstep over the tone tint, the same pairs measured forLetterAvatar— clear of the 3:1 WCAG 1.4.11 asks of a non-text component, in both palettes. - Colour is identity, never status, and eight tones over any real catalogue means collisions are routine — so it is never the only carrier of meaning, and it must not be used to encode a state such as “locked”.