Identicon
A deterministic Bayer mark seeded by a stable id. A circle for a person, a rounded square for a group.
Cohorts
Recent callers
import { Identicon } from "@oration/canon/components/identicon";import { toast } from "@oration/canon/components/toast";export function Hero() { const cohorts = [ { id: "coh_w9_overdue", name: "Suppliers with overdue W-9s", members: 142, }, { id: "coh_net30", name: "Net 30 suppliers", members: 1208 }, { id: "coh_disputes_sep", name: "Disputed invoices in September", members: 37, }, ]; const customers = [ { id: "cus_8f21", name: "Northwind Freight", calls: 14 }, { id: "cus_2c77", name: "Halcyon Logistics", calls: 6 }, { id: "cus_91ab", name: "Orchard Street Produce", calls: 3 }, ]; return ( <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2"> <div className="flex flex-col rounded-xl bg-card p-2 shadow-border"> <p className="px-2 pt-1 pb-2 text-sm font-semibold text-foreground"> Cohorts </p> {cohorts.map((cohort) => ( <button key={cohort.id} type="button" onClick={() => toast.add({ title: cohort.name, description: `${cohort.members.toLocaleString("en-US")} customers match this cohort.`, }) } className="flex items-center gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-colors duration-150 hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40" > <Identicon username={cohort.id} type={RECORD_TYPE} shape="square" size={24} /> <span className="min-w-0 flex-1 truncate text-13 text-foreground"> {cohort.name} </span> <span className="text-xs text-muted-foreground tabular-nums"> {cohort.members.toLocaleString("en-US")} </span> </button> ))} </div> <div className="flex flex-col rounded-xl bg-card p-2 shadow-border"> <p className="px-2 pt-1 pb-2 text-sm font-semibold text-foreground"> Recent callers </p> {customers.map((customer) => ( <button key={customer.id} type="button" onClick={() => toast.add({ title: customer.name, description: `${customer.calls} calls this month.`, }) } className="flex items-center gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-colors duration-150 hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40" > <Identicon username={customer.id} type={RECORD_TYPE} shape="rounded" size={24} /> <span className="min-w-0 flex-1 truncate text-13 text-foreground"> {customer.name} </span> <span className="text-xs text-muted-foreground tabular-nums"> {customer.calls} calls </span> </button> ))} </div> </div> );}Usage#
Identicon paints a small generative mark from a string, so a record without a photo or logo still has a face you can find again: a customer, a cohort, a report's author, an agent template. The same username always draws the same mark. In the suite that mark is Bayer 2x2 in the oklch-mono palette: a circle for a person, a rounded square for a group or a piece of software. It is the base of Agent avatar. The mistake to avoid is seeding it with a display name: rename the record and it becomes a stranger.
When to use
- Beside the name of a person-like record that has no photo: a caller, a report's author. Use
shape="rounded". - Beside a cohort or other group. Use
shape="square". - As IdenticonGroup when several of those records share a row and a count past four.
identityColors(seed)when initials or a monogram need an identity tint. That tint is separate from the mark's palette.
When not to use
- For a teammate with a name and maybe a photo. People get initials in a circle. Use Avatar
- For an AI agent. Use the agent mark, which keeps the Bayer tile and adds the live and draft rings. Use Agent avatar
- For a company, supplier or provider shown by its letters. Use Monogram tile
- As an icon for a nav item, section or feature. A generative mark reads as an identity. Use Iconography
The One Pattern Rule
type="Bayer 2x2" and colorScheme="oklch-mono". A customer, a cohort and an agent share that drawing. Other generators and schemes exist on the component; mixing them in one product makes two records look like two design systems.The Shape Rule
shape="rounded"). Rounded squares are groups and software (shape="square", 8px corners). Agent avatar overrides that corner to 10px at 32px so an agent never passes for a teammate.The Option Hue Rule
identityColors is that tint. It is not the identicon's paint.Anatomy#
- Tile. A canvas sized by
sizein pixels.roundedclips it to a circle.squareclips it withrounded-md(8px).bg-mutedshows through until the bitmap is painted. - Palette. Two colors from
colorScheme, derived from the username.oklch-monois one hue at two lightnesses. The suite does not theme these with tag tokens. - Pattern. The generator named by
type. Bayer 2x2 is a full-bleed dither. Every pixel is filled; there is no empty ground with a floating glyph.
Examples#
Shapes
The same username as a circle and as a rounded square. People are circles. Cohorts, groups and software are rounded squares.
roundedPeoplesquareGroups and softwareimport { Identicon } from "@oration/canon/components/identicon";export function Shapes() { const shapes = [ { shape: "rounded", use: "People" }, { shape: "square", use: "Groups and software" }, ] as const; return ( <div className="flex flex-wrap items-start justify-center gap-10"> {shapes.map((item) => ( <div key={item.shape} className="flex flex-col items-center gap-2" > <Identicon username="cus_8f21" type={RECORD_TYPE} shape={item.shape} size={56} /> <span className="flex flex-col items-center"> <code className="font-mono text-xs text-foreground"> {item.shape} </code> <span className="text-xs text-muted-foreground"> {item.use} </span> </span> </div> ))} </div> );}Generators
type picks the drawing. The suite uses Bayer 2x2 for every record. The other names in identiconTypes are on the component, and they are not a second visual language for a second kind of row.
Bayer 2x2Halftone DotsMarble VeinGlass Orbimport { Identicon } from "@oration/canon/components/identicon";export function Types() { const types = [ "Bayer 2x2", "Halftone Dots", "Marble Vein", "Glass Orb", ] as const; return ( <div className="flex flex-wrap items-start justify-center gap-8"> {types.map((type) => ( <div key={type} className="flex flex-col items-center gap-2"> <Identicon username="cus_8f21" type={type} size={56} /> <code className="font-mono text-xs text-foreground"> {type} </code> </div> ))} </div> );}Color schemes
colorScheme picks the two colors. The suite uses oklch-mono, one hue at two lightnesses, so a column of marks stays quiet.
oklch-monooklch-earthoklch-pasteloklch-vividimport { Identicon } from "@oration/canon/components/identicon";export function Schemes() { const schemes = [ "oklch-mono", "oklch-earth", "oklch-pastel", "oklch-vivid", ] as const; return ( <div className="flex flex-wrap items-start justify-center gap-8"> {schemes.map((scheme) => ( <div key={scheme} className="flex flex-col items-center gap-2"> <Identicon username="cus_8f21" type={RECORD_TYPE} colorScheme={scheme} size={56} /> <code className="font-mono text-xs text-foreground"> {scheme} </code> </div> ))} </div> );}Deterministic usernames
The mark is a pure function of the username. Type another ID to see it redraw, then put the original back.
Change one character and the mark changes; put it back and the same mark returns.
import { Identicon } from "@oration/canon/components/identicon";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import * as React from "react";export function Seeds() { const inputId = React.useId(); const [username, setUsername] = React.useState("cus_8f21"); const value = username.trim() || "cus_8f21"; return ( <div className="flex w-full max-w-md flex-col gap-5"> <div className="flex flex-col gap-1.5"> <Label htmlFor={inputId}>Username</Label> <Input id={inputId} value={username} onChange={(event) => setUsername(event.target.value)} spellCheck={false} className="font-mono" /> </div> <div className="flex items-center gap-4"> <Identicon username={value} type={RECORD_TYPE} shape="rounded" size={40} /> <Identicon username={value} type={RECORD_TYPE} shape="square" size={40} /> </div> <p className="text-xs text-muted-foreground"> Change one character and the mark changes; put it back and the same mark returns. </p> </div> );}Sizes
size is in pixels. Rows use 18 to 24px beside 13px text, record headers 40 to 48px.
import { Identicon } from "@oration/canon/components/identicon";export function Sizes() { return ( <div className="flex flex-wrap items-end justify-center gap-6"> {[16, 20, 24, 32, 40, 48].map((size) => ( <div key={size} className="flex flex-col items-center gap-2"> <Identicon username="coh_net30" type={RECORD_TYPE} shape="square" size={size} /> <span className="text-xs text-muted-foreground tabular-nums"> {size}px </span> </div> ))} </div> );}A group
IdenticonGroup shows four marks, then a count. Hover a mark or the count for the record names.
import { IdenticonGroup } from "@oration/canon/components/identicon";export function Group() { return ( <IdenticonGroup items={[ { id: "coh_w9_overdue", label: "Overdue W-9s" }, { id: "coh_net30", label: "Net 30 suppliers" }, { id: "coh_disputes_sep", label: "Disputed invoices" }, { id: "coh_priority", label: "Priority accounts" }, { id: "coh_new", label: "New this quarter" }, ]} type={RECORD_TYPE} shape="square" size={24} /> );}In a record header
A 48px circle leads a caller, beside the name and its meta line.
Northwind Freight
import { Identicon } from "@oration/canon/components/identicon";import { PhoneIcon } from "lucide-react";export function RecordHeader() { return ( <div className="flex w-full max-w-xl items-center gap-4"> <Identicon username="cus_8f21" type={RECORD_TYPE} shape="rounded" size={48} /> <div className="flex min-w-0 flex-col gap-1"> <h3 className="truncate text-xl font-semibold tracking-[-0.015em] text-foreground"> Northwind Freight </h3> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1.5"> <PhoneIcon aria-hidden="true" className="size-3.5" /> <span className="font-mono">+13125550147</span> </span> <span className="tabular-nums">14 calls this month</span> <span>Last call Sep 28 at 9:14 AM</span> </div> </div> </div> );}States#
| State | Treatment |
|---|---|
| Decorative | Without aria-label, the canvas is aria-hidden="true". This is the normal case, beside a visible name. |
| Labelled | With aria-label, the canvas is role="img" with that name, for a mark shown without its record. |
| Empty, then painted | The server sends an empty muted tile. The bitmap is drawn on the client after mount. Light and dark do not restyle it; the colors are computed, not tokens. |
Behavior#
- The username is hashed and fed to the generator, so the drawing is a pure function of the string, the type and the color scheme.
shape="rounded"isrounded-full.shape="square"isrounded-md. Astyleborder radius, as Agent avatar sets, overrides the square corner.- The canvas is painted at
sizebysizepixels withimageRendering: pixelated, which is the point of a dither and the reason smooth generators look stepped. - It has no motion and no interaction. Wrap it with the name in a link to make the row clickable.
- IdenticonGroup overlaps marks by 8px with a 2px ring in the surface color, shows four, then a
+ncount. Each mark and the count have a tooltip with the record names.
Do and don't#
- Overdue W-9s
- Net 30 suppliers
- Customers
- Reports
- Cohorts
Content#
- An identicon carries no text. The name beside it does the work: Suppliers with overdue W-9s, Halcyon Logistics.
- When it stands alone,
aria-labelis the record's name as people know it, not its ID: Net 30 suppliers, not coh_4471.
Accessibility#
- Decorative by default (
aria-hidden). Screen readers get the record from the visible name. - Set
aria-labelonly when no name is visible; it becomesrole="img". - Color is never the only way to tell records apart. The name is always written.
- The mark does not move, so there is nothing to reduce for motion preferences.
- Group tooltips name the hidden records. The count is still not enough on its own: keep a written name or an accessible name on the row.
Design tokens#
| Token | Used for |
|---|---|
rounded-full | People, when shape is rounded |
rounded-md | Groups and software, when shape is square (8px) |
bg-muted | The tile before the canvas paints, and the group overflow count |
ring-background | The 2px ring that separates overlapping marks in a group |
API reference#
Identicon
The generative mark. Renders a canvas.
Other props spread onto <canvas>.
| Prop | Type | Default | Description |
|---|---|---|---|
usernameRequired | string | No default | A stable ID. Same username, same mark. Replaces the old seed prop. |
type | IdenticonType | "Bayer 2x2" | Which generator draws the tile. The suite uses Bayer 2x2. The full list is the exported identiconTypes array. |
shape | "rounded" | "square" | "rounded" | rounded is a circle, for a person. square is rounded-md, for a group or software. |
size | number | 32 | Width and height in pixels. |
colorScheme | ColorScheme | "oklch-mono" | How the two colors are picked from the username. The suite uses oklch-mono. The full list is the exported colorSchemes array. |
aria-label | string | No default | Makes the mark role="img" with this name. Leave it out beside a visible name. |
className | string | No default | Merged last, so it can override the shape. |
style | React.CSSProperties | No default | Merged after the size. Agent avatar sets borderRadius here. |
IdenticonGroup
A row of marks for several records, with a +n count and tooltips.
| Prop | Type | Default | Description |
|---|---|---|---|
itemsRequired | { id: string; label: string }[] | No default | id seeds the mark. label is the tooltip. |
limit | number | 4 | How many marks to show before the count. |
type | IdenticonType | "Bayer 2x2" | Passed through to each mark. |
shape | "rounded" | "square" | "square" | Passed through to each mark. |
size | number | 24 | Passed through to each mark. |
emptyLabel | React.ReactNode | No default | Rendered in muted text when items is empty. |
stopPropagation | boolean | false | Stops click and keydown from reaching a row or card behind the group. |
className | string | No default | On the row. |
identityHue
(seed: string) => IdentityHue. The hue name for initials, stable across renders, sessions and themes. It does not color the identicon.
No props of its own.
identityColors
(seed: string) => { hue, bg, fg }. The hue plus var(--tag-{hue}) and var(--tag-{hue}-fg), for an initials tile. It does not match the identicon's palette.
No props of its own.
IDENTITY_HUES
The eight identity hues in hue-wheel order: "red", "orange", "amber", "green", "teal", "blue", "violet", "pink". Type IdentityHue is exported too. Indigo and gray are excluded.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The bitmap is drawn in an effect, so the first server paint is an empty muted tile. The old SVG mark rendered on the server.
oklch-mono picks a hue from the whole wheel, so a mark can land near Quiet Indigo. Identity tints for initials still exclude indigo; the mark does not.
identityColors no longer agrees with the mark. A customer shown as a Bayer tile in one app and as tinted initials in another will not share a color.
size is a number in pixels, while Avatar uses sm | default | lg. Call sites use 16, 18, 20, 24, 28, 36, 40 and 48.
Smooth generators (Glass Orb and its neighbours) are painted with imageRendering: pixelated, so they step. Bayer is the one that wants that.
Group tooltips open on hover. The trigger is not a button, so keyboard users don't get the hidden names from the tooltip.