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.

TasksChatCRMDocsHRStarter

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.

tasks

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.

tsx
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

Tasksxs · 24px
Taskssm · 28px
Tasksmd · 36px
Taskslg · 44px
Tasksxl · 56px
SizeTileGlyph
xs24px14px
sm28px16px
md36px16px
lg44px20px
xl56px24px

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

Tasks

an app — square (default)

Tasks

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.

Taskssrc set
Tasksicon fallback
Tasksfull + src

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.

goldgold
tealteal
greengreen
redred
yellowyellow
navynavy
ashash
neutralneutral

A 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.

Appseedindextonetile
Taskstasks3redTasks
Analyticsanalytics3redAnalytics
CRMcrm1tealCRM
Calendarcalendar1tealCalendar
Starterstarter4yellowStarter
Messagesmes4yellowMessages
Helpdeskhelpdesk5navyHelpdesk
HRhr7neutralHR

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.

tsx
// 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

PropTypeDefaultDescription
namerequiredstringThe accessible name of the tile (an sr-only span and the title tooltip), and the default colour seed when seed is not given.
seedstringThe 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.
iconComponentType<{ 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.
srcstring | nullAn 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.
toneAvatarToneName | string | nullPick 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.
classNamestringMerged last, so it wins over the size and tone classes.
...restOmit<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-only span with name on every branch — icon, image or empty — so it is never a mute coloured square, and title is set to name for 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 uses alt="" so the name is not read twice.
  • The glyph runs on the -ink step over the tone tint, the same pairs measured for LetterAvatar — 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”.