@cantera/crew-avatarcomponent
Deterministic construction-crew SVG avatars — monochrome disc, geometric figure, one accent for the trade on the hard hat — generated from a name with zero dependencies, boring-avatars style.
npx shadcn@latest add @cantera/crew-avatarHard hat: labor
The same name always renders the same worker — a salted FNV-1a hash drives every trait, so avatars are SSR-safe and hydration-identical with no randomness at render time. The mark is monochrome by construction: a near-black or near-white canvas, the figure drawn in whichever end the canvas is not, and exactly one chroma — the hard hat, whose color codes the trade and is repeated as text on spec.role, so nothing depends on reading the color alone. The disc needs no theme awareness: it carries a hairline ring one step off its own canvas, so a light disc keeps its edge on a light page. Pass title for a meaningful image (role=img); omit it next to a visible name and the mark stays decorative (aria-hidden). crewAvatarSvg(name) returns standalone markup for non-React surfaces; colors overrides the canvas palette. Not a photo component: when a person has a real picture, compose shadcn's Avatar/AvatarImage/AvatarFallback instead — crew-avatar is for the names that don't.
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | — | The seed. Casing and surrounding whitespace are normalized, so the same person always gets the same worker. |
| size | number | 32 | Rendered square in pixels. Shapes are tuned to stay legible down to 24px. |
| colors | string[] | — | Canvas palette override. The defaults are the two ends of the neutral scale, never the middle; the figure is drawn in whichever end the canvas is not, so an override of mid-tones owns its own contrast. |
| title | string | — | Accessible name — renders role="img" with a <title>. Omit next to a visible name and the mark stays decorative (aria-hidden). |
| Export | Type | Description |
|---|---|---|
| crewAvatarSvg | (name, { size?, colors?, title? }) => string | Standalone <svg> markup for non-React surfaces — emails, canvases, OG images. |
| crewAvatarSpec / crewAvatarShapes | functions | The resolved trait spec — headwear, vest, eyewear, tones, and role, the trade the hat color codes for — plus the renderer-neutral shape tree both renderers share, for custom rendering. |
This is the exact code the CLI installs into your project — you own it from there.
// Every decision is a slice of the name's hash — no Math.random, no Date — so
// server and client render identical markup and avatars never flicker on hydration.
export const CREW_AVATAR_VIEWBOX = 36
const S = CREW_AVATAR_VIEWBOX
const FNV_OFFSET_BASIS = 0x811c9dc5
const FNV_PRIME = 0x01000193
// `basis` doubles as a salt so one name yields many independent streams.
function fnv1a(value: string, basis: number): number {
let hash = basis
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, FNV_PRIME)
}
return hash >>> 0
}
function seedFromName(name: string): number {
return fnv1a(name.trim().toLowerCase(), FNV_OFFSET_BASIS)
}
function trait(seed: number, key: string): number {
return fnv1a(key, seed)
}
function unit(seed: number, key: string, range: number): number {
return trait(seed, key) % range
}
function chance(seed: number, key: string, percent: number): boolean {
return trait(seed, key) % 100 < percent
}
function pick<T>(seed: number, key: string, list: readonly T[]): T {
return list[trait(seed, key) % list.length]
}
// Magnitude from the low bits, sign from the high bits, so they stay independent.
function signed(seed: number, key: string, steps: number, step: number): number {
const value = trait(seed, key)
const magnitude = (value % (steps + 1)) * step
return (value >>> 16) % 2 === 0 ? -magnitude : magnitude
}
function pickWeighted<T extends { weight: number }>(
seed: number,
key: string,
table: readonly T[],
): T {
const total = table.reduce((sum, entry) => sum + entry.weight, 0)
let cursor = trait(seed, key) % total
for (const entry of table) {
cursor -= entry.weight
if (cursor < 0) return entry
}
return table[table.length - 1]
}
function channels(hex: string): [number, number, number] | null {
const raw = hex.trim().replace('#', '')
const full =
raw.length === 3
? `${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`
: raw.length === 6
? raw
: null
if (!full || !/^[0-9a-fA-F]{6}$/.test(full)) return null
return [
Number.parseInt(full.slice(0, 2), 16),
Number.parseInt(full.slice(2, 4), 16),
Number.parseInt(full.slice(4, 6), 16),
]
}
function toHex(rgb: number[]): string {
return `#${rgb
.map((channel) =>
Math.max(0, Math.min(255, Math.round(channel)))
.toString(16)
.padStart(2, '0'),
)
.join('')}`
}
// Consumer-supplied colors reach this: an unparseable value passes through untouched.
function mix(from: string, to: string, weight: number): string {
const a = channels(from)
const b = channels(to)
if (!a || !b) return from
return toHex(a.map((channel, index) => channel + (b[index] - channel) * weight))
}
function shade(hex: string, amount: number): string {
return mix(hex, amount < 0 ? '#000000' : '#ffffff', Math.abs(amount))
}
function luminance(hex: string): number {
const rgb = channels(hex)
if (!rgb) return 0.5
const [r, g, b] = rgb.map((channel) => {
const value = channel / 255
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
export const crewAvatarColors = [
'#0A0A0A',
'#141414',
'#1F1F1F',
'#2E2E2E',
'#E8E8E8',
'#EDEDED',
'#F2F2F2',
'#FAFAFA',
] as const
export type CrewAvatarRole =
| 'labor'
| 'supervisor'
| 'signal'
| 'technical'
| 'safety'
| 'emergency'
const hardHats = [
{ role: 'labor', color: '#F5A623', weight: 5 },
{ role: 'supervisor', color: '#8F8F8F', weight: 4 },
{ role: 'signal', color: '#F97316', weight: 3 },
{ role: 'technical', color: '#0070F3', weight: 2 },
{ role: 'emergency', color: '#E5484D', weight: 1 },
{ role: 'safety', color: '#45A557', weight: 1 },
] as const satisfies readonly { role: CrewAvatarRole; color: string; weight: number }[]
export type CrewAvatarHeadwear = 'hard-hat' | 'beanie'
export type CrewAvatarEyewear = 'none' | 'safety-glasses' | 'goggles'
export interface CrewAvatarOptions {
colors?: readonly string[]
}
export interface CrewAvatarSpec {
/** Stable base36 form of the seed hash — safe as a DOM id fragment. */
id: string
name: string
background: string
ring: string
ink: string
inkMuted: string
inkFaint: string
accent: string
accentDeep: string
/** What the hat color codes for, as text. Never rely on the color alone. */
role: CrewAvatarRole
headwear: CrewAvatarHeadwear
eyewear: CrewAvatarEyewear
earDefenders: boolean
vest: boolean
beard: boolean
/** Degrees, about the base of the neck. */
tilt: number
faceX: number
faceY: number
eyeSpread: number
}
/** Pure and total: any string, even an empty one, yields a complete spec. */
export function crewAvatarSpec(name: string, options: CrewAvatarOptions = {}): CrewAvatarSpec {
const seed = seedFromName(name)
const backgrounds =
options.colors && options.colors.length > 0 ? options.colors : crewAvatarColors
const background = pick(seed, 'background', backgrounds)
const dark = luminance(background) < 0.35
const ink = dark ? '#FAFAFA' : '#101010'
const hat = pickWeighted(seed, 'hard-hat', hardHats)
const accent = dark ? shade(hat.color, 0.06) : shade(hat.color, -0.34)
const eyewearRoll = unit(seed, 'eyewear', 100)
return {
id: seed.toString(36),
name,
background,
ring: mix(background, ink, 0.1),
ink,
inkMuted: mix(ink, background, 0.38),
inkFaint: mix(ink, background, 0.66),
accent,
accentDeep: shade(accent, dark ? -0.22 : -0.18),
role: hat.role,
headwear: chance(seed, 'headwear', 86) ? 'hard-hat' : 'beanie',
eyewear: eyewearRoll < 24 ? 'goggles' : eyewearRoll < 52 ? 'safety-glasses' : 'none',
earDefenders: chance(seed, 'ear-defenders', 30),
vest: chance(seed, 'vest', 66),
beard: chance(seed, 'beard', 36),
tilt: signed(seed, 'tilt', 5, 1),
faceX: signed(seed, 'face-x', 4, 0.3),
faceY: signed(seed, 'face-y', 3, 0.25),
eyeSpread: unit(seed, 'eye-spread', 7) * 0.2,
}
}
// `id` is a React key, never a DOM id — emitting it would collide when two
// avatars share a page.
export type CrewAvatarShape =
| {
id: string
kind: 'rect'
x: number
y: number
width: number
height: number
rx?: number
fill: string
}
| {
id: string
kind: 'circle'
cx: number
cy: number
r: number
fill?: string
stroke?: string
strokeWidth?: number
}
| {
id: string
kind: 'path'
d: string
fill?: string
stroke?: string
strokeWidth?: number
round?: boolean
}
| { id: string; kind: 'group'; transform: string; children: CrewAvatarShape[] }
const HEAD_X = 11.6
const HEAD_TOP = 9.2
const HEAD_WIDTH = 12.8
const HEAD_HEIGHT = 13.6
const HEAD_RADIUS = 4.6
const SHOULDER_TOP = 27.2
export function crewAvatarShapes(spec: CrewAvatarSpec): CrewAvatarShape[] {
return [
{ id: 'ground', kind: 'rect', x: 0, y: 0, width: S, height: S, fill: spec.background },
{
id: 'head',
kind: 'group',
transform: `rotate(${spec.tilt} 18 27)`,
children: headShapes(spec),
},
...bodyShapes(spec),
{ id: 'ring', kind: 'circle', cx: 18, cy: 18, r: 17.5, stroke: spec.ring, strokeWidth: 1 },
]
}
function headShapes(spec: CrewAvatarSpec): CrewAvatarShape[] {
const shapes: CrewAvatarShape[] = [
{
id: 'skull',
kind: 'rect',
x: HEAD_X,
y: HEAD_TOP,
width: HEAD_WIDTH,
height: HEAD_HEIGHT,
rx: HEAD_RADIUS,
fill: spec.ink,
},
]
if (spec.beard) {
shapes.push({
id: 'beard',
kind: 'rect',
x: 12.2,
y: 18.4,
width: 11.6,
height: 5.6,
rx: 2.4,
fill: spec.inkMuted,
})
}
shapes.push({
id: 'face',
kind: 'group',
transform: `translate(${round(spec.faceX)} ${round(spec.faceY)})`,
children: faceShapes(spec),
})
shapes.push(...headwearShapes(spec))
if (spec.earDefenders) {
shapes.push(
{
id: 'defender-left',
kind: 'rect',
x: 7.6,
y: 15.8,
width: 3.8,
height: 6,
rx: 1.9,
fill: spec.ink,
},
{
id: 'pad-left',
kind: 'rect',
x: 8.6,
y: 17,
width: 1.8,
height: 3.6,
rx: 0.9,
fill: spec.inkFaint,
},
{
id: 'defender-right',
kind: 'rect',
x: 24.6,
y: 15.8,
width: 3.8,
height: 6,
rx: 1.9,
fill: spec.ink,
},
{
id: 'pad-right',
kind: 'rect',
x: 25.6,
y: 17,
width: 1.8,
height: 3.6,
rx: 0.9,
fill: spec.inkFaint,
},
)
}
return shapes
}
function faceShapes(spec: CrewAvatarSpec): CrewAvatarShape[] {
if (spec.eyewear === 'none') {
return [
{
id: 'eye-left',
kind: 'rect',
x: round(14.2 - spec.eyeSpread),
y: 16.6,
width: 1.7,
height: 2.4,
rx: 0.85,
fill: spec.background,
},
{
id: 'eye-right',
kind: 'rect',
x: round(20.1 + spec.eyeSpread),
y: 16.6,
width: 1.7,
height: 2.4,
rx: 0.85,
fill: spec.background,
},
]
}
if (spec.eyewear === 'safety-glasses') {
return [
{
id: 'glasses',
kind: 'rect',
x: 11.2,
y: 15.6,
width: 13.6,
height: 4.2,
rx: 2.1,
fill: spec.inkFaint,
},
{
id: 'lens-left',
kind: 'rect',
x: 12.4,
y: 16.6,
width: 4.2,
height: 2.2,
rx: 1.1,
fill: spec.background,
},
{
id: 'lens-right',
kind: 'rect',
x: 19.4,
y: 16.6,
width: 4.2,
height: 2.2,
rx: 1.1,
fill: spec.background,
},
]
}
return [
{
id: 'strap',
kind: 'rect',
x: 9.6,
y: 16.8,
width: 16.8,
height: 1.6,
rx: 0.8,
fill: spec.inkFaint,
},
{
id: 'goggle-body',
kind: 'rect',
x: 11.2,
y: 15,
width: 13.6,
height: 5.2,
rx: 2.6,
fill: spec.ink,
},
{ id: 'goggle-left', kind: 'circle', cx: 14.9, cy: 17.6, r: 1.5, fill: spec.background },
{ id: 'goggle-right', kind: 'circle', cx: 21.1, cy: 17.6, r: 1.5, fill: spec.background },
]
}
function headwearShapes(spec: CrewAvatarSpec): CrewAvatarShape[] {
if (spec.headwear === 'beanie') {
return [
{
id: 'beanie-dome',
kind: 'path',
d: 'M11.4 14.4a6.6 6.6 0 0 1 13.2 0z',
fill: spec.accent,
},
{
id: 'beanie-band',
kind: 'rect',
x: 11,
y: 13.2,
width: 14,
height: 2.4,
rx: 1.2,
fill: spec.accentDeep,
},
]
}
return [
{ id: 'hat-dome', kind: 'path', d: 'M11.8 13.4a6.2 6.2 0 0 1 12.4 0z', fill: spec.accent },
{
id: 'hat-ridge',
kind: 'rect',
x: 17.3,
y: 8,
width: 1.4,
height: 5,
rx: 0.7,
fill: spec.accentDeep,
},
{
id: 'hat-brim',
kind: 'rect',
x: 7.6,
y: 12.4,
width: 20.8,
height: 2.4,
rx: 1.2,
fill: spec.accent,
},
]
}
function bodyShapes(spec: CrewAvatarSpec): CrewAvatarShape[] {
const shoulders = { x: 6.2, y: SHOULDER_TOP, width: 23.6, height: 12, rx: 5.6 }
if (!spec.vest) {
return [{ id: 'shoulders', kind: 'rect', ...shoulders, fill: spec.ink }]
}
return [
{ id: 'shoulders', kind: 'rect', ...shoulders, fill: spec.ink },
{
id: 'chevron',
kind: 'path',
d: 'M11.8 27.2h2.6L18 30.6l3.6-3.4h2.6L18 33z',
fill: spec.background,
},
]
}
function round(value: number): number {
return Math.round(value * 100) / 100
}
function escapeXml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
function attributes(shape: Exclude<CrewAvatarShape, { kind: 'group' }>): string {
switch (shape.kind) {
case 'rect':
return [
`x="${shape.x}"`,
`y="${shape.y}"`,
`width="${shape.width}"`,
`height="${shape.height}"`,
shape.rx === undefined ? '' : `rx="${shape.rx}"`,
`fill="${shape.fill}"`,
]
.filter(Boolean)
.join(' ')
case 'circle':
return [
`cx="${shape.cx}"`,
`cy="${shape.cy}"`,
`r="${shape.r}"`,
shape.fill ? `fill="${shape.fill}"` : 'fill="none"',
shape.stroke ? `stroke="${shape.stroke}"` : '',
shape.strokeWidth ? `stroke-width="${shape.strokeWidth}"` : '',
]
.filter(Boolean)
.join(' ')
default:
return [
`d="${shape.d}"`,
shape.fill ? `fill="${shape.fill}"` : 'fill="none"',
shape.stroke ? `stroke="${shape.stroke}"` : '',
shape.strokeWidth ? `stroke-width="${shape.strokeWidth}"` : '',
shape.round ? 'stroke-linecap="round"' : '',
]
.filter(Boolean)
.join(' ')
}
}
function serialize(shapes: CrewAvatarShape[]): string {
return shapes
.map((shape) =>
shape.kind === 'group'
? `<g transform="${shape.transform}">${serialize(shape.children)}</g>`
: `<${shape.kind} ${attributes(shape)}/>`,
)
.join('')
}
export interface CrewAvatarSvgOptions extends CrewAvatarOptions {
size?: number
/** Present means the avatar carries meaning: it gets a name, not `aria-hidden`. */
title?: string
}
export function crewAvatarSvg(name: string, options: CrewAvatarSvgOptions = {}): string {
const { size = 32, title, ...rest } = options
const spec = crewAvatarSpec(name, rest)
const clipId = `crew-avatar-${spec.id}`
const label = title ? `role="img" aria-label="${escapeXml(title)}"` : 'aria-hidden="true"'
return [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${S} ${S}"`,
` width="${size}" height="${size}" fill="none" ${label}>`,
title ? `<title>${escapeXml(title)}</title>` : '',
`<clipPath id="${clipId}"><circle cx="${S / 2}" cy="${S / 2}" r="${S / 2}"/></clipPath>`,
`<g clip-path="url(#${clipId})">${serialize(crewAvatarShapes(spec))}</g>`,
'</svg>',
].join('')
}
import type * as React from 'react'
import {
CREW_AVATAR_VIEWBOX,
type CrewAvatarShape,
crewAvatarShapes,
crewAvatarSpec,
} from '@/lib/crew-avatar-spec'
export interface CrewAvatarProps
extends Omit<React.ComponentPropsWithoutRef<'svg'>, 'children' | 'height' | 'title' | 'width'> {
/** The seed. The same name always draws the same worker. */
name: string
size?: number
colors?: readonly string[]
/** Pass the accessible name when the avatar is the only thing identifying
* the person; leave it off next to a visible name. */
title?: string
}
// The clip id comes from the seed hash rather than `useId`, which keeps this
// renderable from a server component and identical across hydration.
export function CrewAvatar({
name,
size = 32,
colors,
title,
className,
...props
}: CrewAvatarProps) {
const spec = crewAvatarSpec(name, { colors })
const clipId = `crew-avatar-${spec.id}`
const half = CREW_AVATAR_VIEWBOX / 2
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${CREW_AVATAR_VIEWBOX} ${CREW_AVATAR_VIEWBOX}`}
width={size}
height={size}
fill="none"
role={title ? 'img' : undefined}
aria-label={title}
aria-hidden={title ? undefined : true}
className={className}
{...props}
>
{title ? <title>{title}</title> : null}
<clipPath id={clipId}>
<circle cx={half} cy={half} r={half} />
</clipPath>
<g clipPath={`url(#${clipId})`}>{renderShapes(crewAvatarShapes(spec))}</g>
</svg>
)
}
/** `shape.id` is a React key only — emitting it as a DOM id would collide the
* moment two avatars share a page. */
function renderShapes(shapes: CrewAvatarShape[]): React.ReactNode {
return shapes.map((shape) => {
switch (shape.kind) {
case 'group':
return (
<g key={shape.id} transform={shape.transform}>
{renderShapes(shape.children)}
</g>
)
case 'circle':
return (
<circle
key={shape.id}
cx={shape.cx}
cy={shape.cy}
r={shape.r}
fill={shape.fill ?? 'none'}
stroke={shape.stroke}
strokeWidth={shape.strokeWidth}
/>
)
case 'rect':
return (
<rect
key={shape.id}
x={shape.x}
y={shape.y}
width={shape.width}
height={shape.height}
rx={shape.rx}
fill={shape.fill}
/>
)
default:
return (
<path
key={shape.id}
d={shape.d}
fill={shape.fill ?? 'none'}
stroke={shape.stroke}
strokeWidth={shape.strokeWidth}
strokeLinecap={shape.round ? 'round' : undefined}
/>
)
}
})
}