Avatar Status
One avatar carrying its own state — initials derived from the name, a presence ring whose word joins the accessible name, a corner badge slot, five sizes and a circle or squircle frame.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/avatar-status.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AvatarStatus" component: one face that
carries its own state. No runtime dependency beyond the cn() class-merge helper
— no image library, no icon set (the badge arrives as a ReactNode).
Contract
- forwardRef<HTMLSpanElement>; props extend React.HTMLAttributes<HTMLSpanElement>
and add:
name: string (required — an unnamed face is unusable),
src?: string, initials?: string,
presence?: "online" | "away" | "busy" | "offline" | null,
presenceLabel?: string, presenceRing?: boolean (default true),
badge?: React.ReactNode, badgeLabel?: string,
badgePlacement?: "top-start" | "top-end" | "bottom-start" | "bottom-end"
(default "top-end"),
size?: "xs" | "sm" | "md" | "lg" | "xl" (default "md"),
shape?: "circle" | "squircle" (default "circle"),
surface?: "background" | "card" (default "background"),
tint?: boolean (default true).
- The root span takes the ref, the merged className and every remaining native
prop. role="img" and aria-label are written BEFORE the spread, so a consumer
can still override either one (aria-hidden it inside a row that already prints
the name, for instance).
- Keep one table keyed by size holding frame / squircle radius / dot / halo /
badge classes together, so adding a size stays a one-line change and the
proportions can never drift apart at the extremes.
Behavior
- Initials: trim, split on whitespace, take the first CODE POINT of the first
word plus the first code point of the last word, uppercase. charAt would slice
an emoji or a supplementary-plane CJK name in half and render a replacement
box. A single word yields one letter; a name with nothing left in it degrades
to "?" rather than an empty circle. `initials` overrides the whole derivation
(team codes, bots).
- Tint: hash the name into 1..5 and colour the monogram with var(--chart-N)
(Math.imul rolling hash, >>> 0 so a long name cannot wrap negative and hand %
a negative index). Same person, same colour, forever — and it is a pure
function of a prop, so SSR and hydration agree. tint=false falls back to
text-muted-foreground.
- Image phases — "pending" | "ready" | "failed", not a single broken flag:
the monogram is the floor and is always mounted; the img renders on top at
opacity 0 and cross-fades in only once it has actually decoded; a failure
drops the img element entirely, so the browser's broken-image glyph never
appears. The floor fades out when the photo arrives, because a transparent
PNG/SVG avatar would otherwise show letters through it.
- Two things the load event alone cannot cover: (1) a server-rendered img
usually finishes loading — or 404ing — BEFORE React attaches onLoad/onError,
and an event that already happened never fires again, so on ref attach read
node.complete and node.naturalWidth and settle the phase from that;
(2) a src swap on a recycled row must reset the phase to "pending" DURING
render (compare a previous-src state value and set both), never in an effect,
otherwise the new person inherits the previous person's failure for a frame.
src="" counts as no src at all.
- Presence: colour is never the only carrier. Each state also owns a shape cut
out of the dot in the surface colour — online solid, away a crescent (an
offset disc at 3/4 of the dot, translated a third of its own width), busy a
do-not-disturb bar, offline a hollow ring — and the state's WORD is joined
into the avatar's accessible name. presence omitted or null renders no
indicator at all: "unknown" is not a presence.
- Accessible name: one sentence, not three nodes — [name, presence word,
badgeLabel].filter(Boolean).join(", "), so a screen reader says "Ada Lovelace,
Busy, Team admin" instead of a pile of fragments. presenceLabel replaces the
word for translation while the dot keeps its shape.
- Claim-to-own corners: presence lives at the bottom-end. If the badge asks for
that same corner, presence steps to the opposite one instead of stacking
underneath — an indicator that is covered is worse than one that moved. The
badge slot is aria-hidden (it sits inside role="img", where descendants are
presentational anyway) and pointer-events-none, which is why its meaning has
to travel through badgeLabel. A badge that must be clickable is not a badge:
drop role="img" and render your own button as a sibling of the frame.
- Keyboard and focus: there is none, deliberately. Nothing here is focusable,
nothing can go inert under the user, and there is no control to hand focus to
— wrap the whole thing in your own link, button or tooltip trigger when the
face has to be interactive, and let that wrapper own the keyboard contract.
- Cleanup: no timer, no rAF, no listener, no observer, no effect. The only async
is the img element's own load/error, which dies with the node, and the src
reset happens during render — so there is nothing left to cancel on unmount.
Rendering & styling
- Semantic tokens only. Frame bg-muted; monogram var(--chart-1..5) or
text-muted-foreground; presence rides the chart tokens (online var(--chart-2),
away var(--chart-4), busy var(--chart-5)) with offline deliberately tint-free
on bg-muted-foreground/60 — three colours plus one grey reads faster than four
colours competing. Halo colour goes through an inline borderColor with the
same token; offline uses border-muted-foreground/40.
- The dot wears a ring in the surface token and its cut-out mark is filled with
the same token, which is the entire job of the `surface` prop: on a page it is
ring-background / bg-background, inside a card ring-card / bg-card. Get it
wrong and dark mode shows a black halo on a grey card.
- Radius flows from one place: the root carries rounded-full or the size's
squircle radius (rounded-md…rounded-2xl, all derived from --radius), and the
clip, the halo and the corner items use rounded-[inherit] — so overriding the
radius once through className re-shapes everything.
- Corner items use LOGICAL insets (top/bottom + start/end), so "end" is the
trailing edge in RTL too. On a circle the box corner already lands on the rim
at 45°, so a plain inset of 0 is correct; a squircle gets -m-0.5 to keep the
sticker straddling its corner (with one inset auto, the negative margin on the
auto side has no effect, which is why a single class works for all four).
- Motion is decoration: one 200ms opacity cross-fade behind
motion-reduce:transition-none. With motion off the photo simply appears — the
fallback, the presence state and the accessible name never depend on it.
- cn() merges every className, and the size classes live on the root, so a
consumer can override the size, the radius or the ring in one prop.
Customization levers
- Density: the whole scale is the size table — change one row (frame, dot, halo
inset, badge text/icon size) or add an "xxl" row; nothing is measured in JS,
so nothing else needs to know.
- Presence palette and vocabulary: the tint map and the label map are two plain
records — swap var(--chart-2) for the token your product already uses for
"healthy", or add a fifth state (e.g. "in a meeting") by adding one row to
each map plus one branch to the mark component.
- Indicator weight: presenceRing=false keeps the dot and drops the halo (right
for dense list rows); inverting that — halo only, no dot — is a two-line edit
if your rows are already tight.
- Shape: circle vs squircle is one radius decision; a fully square frame is
className="rounded-none" and the clip, halo and corners follow.
- Badge slot: it is unskinned on purpose. Pass a chip that brings its own
background plus a ring in the surface token (a count, a lucide icon, a shadcn
Badge). Anything decorative and small works; anything interactive does not.
- Tint seed: it hashes `name`. Hash a stable user id instead if display names
change often, or drop tint entirely for a uniform muted monogram.
- Composition: hover names belong to a shadcn Tooltip around this component,
"who is on this?" belongs to an avatar stack, and a clickable face belongs to
a wrapping button — keep this leaf presentational and those stay easy.Concepts
- Initials floor — the monogram is not a fallback that appears after a failure, it is the layer everything else sits on: it renders first, the photo fades in over it, and a broken URL simply never covers it.
- Presence is a word, not just a hue — the same state is carried three ways: a tinted halo, a shape cut out of the dot (solid / crescent / bar / hollow), and the spoken word joined into the avatar's accessible name, so neither a colour-blind read nor a screen reader loses it.
- Image phases, not a broken flag —
pending/ready/failedalso covers the two cases anonErrorhandler cannot: an image that already finished before hydration (re-readcompleteandnaturalWidthon attach) and a recycled row whosesrcchanged (reset during render, never in an effect). - Claim-to-own corner — the badge owns whichever corner it asks for and presence relocates to the opposite one; an indicator hidden under a sticker is worse than an indicator that moved.
- Surface-matched cutout — the dot's ring and its punched mark are painted in the token of whatever the avatar sits on (
backgroundorcard), which is what makes the seam read as carved instead of as a dark halo in dark mode. - A display leaf on purpose — nothing is focusable and no click handler exists, so the keyboard contract belongs to the link, button or tooltip you wrap around it, and this component never fights the wrapper for focus.
Contrast Checker
A WCAG contrast readout for one colour pair — resolved through the live cascade, composited on a canvas, with AA/AAA verdicts and the nearest passing lightness.
Barcode
A vector Code 128 / EAN-13 renderer — automatic subset switching, a real check digit, spec quiet zones, and a refusal that names what cannot be encoded.