Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type AvatarPresence = "online" | "away" | "busy" | "offline"
export type AvatarStatusSize = "xs" | "sm" | "md" | "lg" | "xl"
export type AvatarStatusShape = "circle" | "squircle"
export type AvatarStatusCorner = "top-start" | "top-end" | "bottom-start" | "bottom-end"
/** Which token the cutouts have to match — whatever surface this avatar is sitting on. */
export type AvatarStatusSurface = "background" | "card"

/* ------------------------------------------------------------------ *

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/avatar-status.json

Prompt

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 flagpending / ready / failed also covers the two cases an onError handler cannot: an image that already finished before hydration (re-read complete and naturalWidth on attach) and a recycled row whose src changed (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 (background or card), 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.

On This Page