{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"avatar-clean","type":"registry:ui","title":"Avatar (Clean)","description":"A person's picture or initials on a soft color of their own – rounded squares for workspaces, an agent's pixel mark that twinkles while it works, a status dot and a group that overlaps and ends in +N","dependencies":[],"files":[{"path":"registry/avatar-clean/avatar-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  Children,\n  cloneElement,\n  isValidElement,\n  useState,\n  type ComponentPropsWithRef,\n  type CSSProperties,\n  type ReactElement,\n} from \"react\";\n\n/*\n * Avatar – a single self-contained file, clean style.\n * A person's picture, falling back to their initials on a soft color of their own; workspaces as rounded squares; an\n * agent as its own pixel mark that twinkles while it works; a status dot; and AvatarGroup, which overlaps them and\n * ends in +N.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type AvatarSize = 20 | 24 | 32 | 40;\n\nexport type AvatarProps = Omit<ComponentPropsWithRef<\"span\">, \"children\"> & {\n  /** Who it is – the initials, the accessible name and the color come from it */\n  name?: string;\n  /** The picture; while it loads, and when it fails, the initials show instead */\n  src?: string;\n  /** 20, 24, 32 or 40 pixels */\n  size?: AvatarSize;\n  /** circle: people · square: a rounded square for workspaces, teams and agents */\n  shape?: \"circle\" | \"square\";\n  /** A dot on the corner: online green, away amber, busy red – also read out after the name */\n  status?: \"online\" | \"away\" | \"busy\";\n  /** person: picture or initials · agent: the agent's pixel mark, never initials */\n  kind?: \"person\" | \"agent\";\n  /** What the color and an agent's pattern are made from, e.g. a user or agent id – the name when left out */\n  seed?: string;\n  /** agent: the mark's color – blue when left out */\n  color?: string;\n  /** agent: the mark twinkles in a wave while the agent works */\n  working?: boolean;\n};\n\nexport function Avatar({\n  name,\n  src,\n  size = 32,\n  shape,\n  status,\n  kind = \"person\",\n  seed,\n  color,\n  working,\n  className,\n  style,\n  ...rest\n}: AvatarProps) {\n  // The picture that failed or finished loading – kept per src, so a new src tries again\n  const [failed, setFailed] = useState<string | null>(null);\n  const [loaded, setLoaded] = useState<string | null>(null);\n\n  const agent = kind === \"agent\";\n  const scale = SIZES.includes(size) ? size : 32;\n  const form = shape === \"circle\" || shape === \"square\" ? shape : agent ? \"square\" : \"circle\";\n  const key = (seed ?? name ?? \"\").trim();\n  const showImage = !!src && failed !== src;\n  const ready = showImage && loaded === src;\n  const who = name?.trim();\n  // \"Mara Lindqvist, online\" – an aria-label of your own wins; with no name at all it is left to the text beside it\n  const label = rest[\"aria-label\"] ?? (who ? (status && STATUS_LABEL[status] ? `${who}, ${STATUS_LABEL[status]}` : who) : undefined);\n\n  return (\n    <>\n      {/* React 19 hoists this into <head> once, no matter how many avatars render */}\n      <style href=\"ui-avatar-clean\" precedence=\"default\">\n        {css}\n      </style>\n      <span\n        className={[\"ui-avtc\", className].filter(Boolean).join(\" \")}\n        data-size={scale}\n        data-shape={form}\n        data-kind={agent ? \"agent\" : \"person\"}\n        data-hue={agent || !key ? undefined : hueOf(key)}\n        data-ready={ready ? \"\" : undefined}\n        data-working={agent && working ? \"\" : undefined}\n        role={label ? \"img\" : undefined}\n        aria-label={label}\n        aria-hidden={label ? undefined : true}\n        style={agent && color ? ({ \"--ui-avtc-mark\": color, ...style } as CSSProperties) : style}\n        {...rest}\n      >\n        <span className=\"ui-avtc__face\">\n          {agent ? <Mark cells={pattern(key || \"agent\")} /> : <Initials name={name ?? \"\"} />}\n          {showImage && (\n            <img\n              className=\"ui-avtc__img\"\n              src={src}\n              alt=\"\"\n              loading=\"lazy\"\n              decoding=\"async\"\n              draggable={false}\n              onLoad={() => setLoaded(src)}\n              onError={() => setFailed(src)}\n              // A cached picture can finish before React listens – take it as loaded right away\n              ref={(el) => {\n                if (el?.complete && el.naturalWidth > 0) setLoaded(src);\n              }}\n            />\n          )}\n        </span>\n        {status && STATUS_LABEL[status] && <span className=\"ui-avtc__status\" data-status={status} aria-hidden=\"true\" />}\n      </span>\n    </>\n  );\n}\n\nexport type AvatarGroupProps = ComponentPropsWithRef<\"div\"> & {\n  /** How many avatars show before the rest becomes +N */\n  max?: number;\n  /** The size of every avatar in the group and of the +N */\n  size?: AvatarSize;\n  /** How many there are in all, when you pass only the first few – +N counts from it */\n  total?: number;\n};\n\n/** Avatars that overlap, each cut out of the one before by a ring in the background color, ending in +N */\nexport function AvatarGroup({ max = 4, size = 32, total, className, children, ...rest }: AvatarGroupProps) {\n  const items = Children.toArray(children).filter(isValidElement) as ReactElement<AvatarProps>[];\n  const scale = SIZES.includes(size) ? size : 32;\n  const limit = Math.max(1, Math.floor(Number.isFinite(max) ? max : 4));\n  const all = Math.max(items.length, Math.floor(Number.isFinite(total) ? (total as number) : 0));\n  const shown = items.slice(0, limit);\n  const more = all - shown.length;\n  const last = shown[shown.length - 1]?.props.shape ?? (shown[shown.length - 1]?.props.kind === \"agent\" ? \"square\" : \"circle\");\n\n  return (\n    <>\n      <style href=\"ui-avatar-clean\" precedence=\"default\">\n        {css}\n      </style>\n      <div role=\"group\" className={[\"ui-avtc__group\", className].filter(Boolean).join(\" \")} data-size={scale} {...rest}>\n        {shown.map((item) => (item.type === Avatar ? cloneElement(item, { size: scale }) : item))}\n        {more > 0 && (\n          <span className=\"ui-avtc__more\" data-shape={last} role=\"img\" aria-label={`${new Intl.NumberFormat().format(more)} more`}>\n            {more > 99 ? \"99+\" : `+${more}`}\n          </span>\n        )}\n      </div>\n    </>\n  );\n}\n\nconst SIZES: number[] = [20, 24, 32, 40];\nconst STATUS_LABEL: Record<string, string> = { online: \"online\", away: \"away\", busy: \"busy\" };\n\n/** One of eight soft colors, always the same for the same name or id */\nconst hueOf = (key: string) => {\n  let h = 7;\n  for (const c of key) h = (h * 31 + c.codePointAt(0)!) >>> 0;\n  return String(h % 8);\n};\n\n/* ---------- Initials ---------- */\n\ntype Segmenter = { segment: (text: string) => Iterable<{ segment: string }> };\nlet segmenter: Segmenter | null | undefined;\n/** Whole characters – \"é\", \"👩🏽‍💻\" and flags stay one – with Intl.Segmenter where it exists */\nconst graphemes = (text: string): string[] => {\n  if (segmenter === undefined) {\n    const S = (Intl as unknown as { Segmenter?: new (locale?: string, options?: { granularity: string }) => Segmenter }).Segmenter;\n    segmenter = S ? new S(undefined, { granularity: \"grapheme\" }) : null;\n  }\n  return segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : Array.from(text);\n};\nconst LETTER = /[\\p{L}\\p{N}]/u;\nconst EMOJI = /\\p{Extended_Pictographic}|\\p{Regional_Indicator}/u;\n/** \"III\", \"Jr.\" and the like close a name but don't make an initial – \"Christopher Montgomery III\" is CM */\nconst SUFFIX = /^(jr|sr|ii|iii|iv|vi|phd|md|esq)\\.?$/i;\n\n/** Initials from the first and last word: \"Mara Lindqvist\" → ML, \"Cher\" → C, \"🦊 Fox Team\" → 🦊, \"(Ana) Ruiz\" → AR */\nfunction initialsOf(name: string) {\n  const words = name.trim().split(/\\s+/).filter(Boolean);\n  if (!words.length) return \"\";\n  const lead = graphemes(words[0])[0];\n  if (lead && EMOJI.test(lead) && !LETTER.test(lead)) return lead;\n  // Each word's first letter or digit, skipping quotes, brackets and other marks in front of it\n  const firsts = words\n    .filter((w, i, all) => !(i > 0 && i === all.length - 1 && SUFFIX.test(w)))\n    .map((w) => graphemes(w).find((g) => LETTER.test(g)))\n    .filter((g): g is string => !!g);\n  if (!firsts.length) return lead ?? \"\";\n  const pair = firsts.length > 1 ? firsts[0] + firsts[firsts.length - 1] : firsts[0];\n  return pair.toLocaleUpperCase();\n}\n\nfunction Initials({ name }: { name: string }) {\n  const text = initialsOf(name);\n  return (\n    <span className=\"ui-avtc__initials\" dir=\"auto\" aria-hidden=\"true\">\n      {text || (\n        // No name: a person outline – Hugeicons Free (MIT, © Hugeicons, hugeicons.com)\n        <svg className=\"ui-avtc__anon\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n          <path d=\"M20 21C19.713 17.269 16.729 14.315 12.995 14.066L12 14C11.645 14.01 11.313 14.023 11.001 14.038C7.3 14.219 4.284 17.306 4 21\" />\n          <circle cx=\"12\" cy=\"7\" r=\"4\" />\n        </svg>\n      )}\n    </span>\n  );\n}\n\n/* ---------- The agent's pixel mark ---------- */\n\n/** Twinkle order of the mark – a diagonal wave from the top left */\nconst WAVE = [0, 120, 240, 120, 240, 360, 240, 360, 480];\n\n/** An agent's own 3×3 pattern, mirrored left to right, from its id – never fewer than five squares */\nconst pattern = (id: string) => {\n  let h = 0;\n  for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0;\n  for (let k = 0; k < 64; k++) {\n    const bits = (h >> (k % 26)) & 63;\n    const cells = [0, 1, 2].flatMap((r) => {\n      const a = (bits >> (r * 2)) & 1;\n      const b = (bits >> (r * 2 + 1)) & 1;\n      return [a, b, a];\n    });\n    if (cells.filter(Boolean).length >= 5) return cells;\n    h = (h * 1103515245 + 12345) >>> 0;\n  }\n  return [1, 0, 1, 0, 1, 0, 1, 0, 1];\n};\n\nfunction Mark({ cells }: { cells: number[] }) {\n  return (\n    <span className=\"ui-avtc__mark\" aria-hidden=\"true\">\n      {cells.map((on, i) => (\n        <i key={i} data-on={on ? \"\" : undefined} style={{ animationDelay: `${WAVE[i]}ms` }} />\n      ))}\n    </span>\n  );\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors as variables – override them on .ui-avtc or .ui-avtc__group (see Theming in the docs) */\n.ui-avtc,\n.ui-avtc__group {\n  --ui-avtc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-avtc-text: rgba(0, 0, 0, 0.61);\n  --ui-avtc-fill: var(--aui-subtle, rgba(0, 0, 0, 0.06));\n  --ui-avtc-tile: var(--aui-surface, #ffffff);\n  --ui-avtc-edge: rgba(0, 0, 0, 0.08);\n  /* The ring that cuts each avatar out of the one before – set it to the background the group sits on */\n  --ui-avtc-ring: var(--aui-surface, #ffffff);\n  --ui-avtc-mark: rgba(0, 96, 226, 0.78);\n  --ui-avtc-online: var(--aui-green, #5fc88a);\n  --ui-avtc-away: var(--aui-amber, #e8b000);\n  --ui-avtc-busy: var(--aui-red, #f04438);\n  /* The soft colors behind initials: lightness and saturation of the tint and of the letters */\n  --ui-avtc-bg-l: 93%;\n  --ui-avtc-bg-s: 70%;\n  --ui-avtc-fg-l: 36%;\n  --ui-avtc-fg-s: 48%;\n\n  --s: 32px;\n  --fs: 12px;\n  --dot: 9px;\n  --sq: 4px;\n  --gap: 2px;\n}\n.ui-avtc[data-size=\"20\"], .ui-avtc__group[data-size=\"20\"] { --s: 20px; --fs: 8.5px; --dot: 6px; --sq: 3px; --gap: 1px; }\n.ui-avtc[data-size=\"24\"], .ui-avtc__group[data-size=\"24\"] { --s: 24px; --fs: 10px; --dot: 7px; --sq: 3px; --gap: 1.5px; }\n.ui-avtc[data-size=\"40\"], .ui-avtc__group[data-size=\"40\"] { --s: 40px; --fs: 14px; --dot: 10px; --sq: 5px; --gap: 2.5px; }\n\n.ui-avtc {\n  position: relative;\n  display: inline-grid;\n  flex: none;\n  box-sizing: border-box;\n  width: var(--s);\n  height: var(--s);\n  vertical-align: middle;\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  -webkit-font-smoothing: antialiased;\n  user-select: none;\n}\n.ui-avtc__face {\n  position: relative;\n  display: grid;\n  place-items: center;\n  overflow: hidden;\n  width: 100%;\n  height: 100%;\n  border-radius: 50%;\n  background: var(--ui-avtc-fill);\n  color: var(--ui-avtc-text);\n}\n/* Rounded squares keep the same curve at every size: a quarter of the side */\n.ui-avtc[data-shape=\"square\"] .ui-avtc__face { border-radius: calc(var(--s) / 4); }\n\n/* Initials sit on a soft color of their own, picked from the name */\n.ui-avtc[data-hue] .ui-avtc__face {\n  background: hsl(var(--h) var(--ui-avtc-bg-s) var(--ui-avtc-bg-l));\n  color: hsl(var(--h) var(--ui-avtc-fg-s) var(--ui-avtc-fg-l));\n}\n.ui-avtc[data-hue=\"0\"] { --h: 214; }\n.ui-avtc[data-hue=\"1\"] { --h: 152; }\n.ui-avtc[data-hue=\"2\"] { --h: 40; }\n.ui-avtc[data-hue=\"3\"] { --h: 22; }\n.ui-avtc[data-hue=\"4\"] { --h: 330; }\n.ui-avtc[data-hue=\"5\"] { --h: 268; }\n.ui-avtc[data-hue=\"6\"] { --h: 174; }\n.ui-avtc[data-hue=\"7\"] { --h: 196; }\n\n.ui-avtc__initials {\n  grid-area: 1 / 1;\n  font-size: var(--fs);\n  font-weight: 500;\n  line-height: 1;\n  letter-spacing: 0;\n  white-space: nowrap;\n}\n.ui-avtc__anon {\n  display: block;\n  width: calc(var(--s) * 0.55);\n  height: calc(var(--s) * 0.55);\n}\n\n/* The picture covers the initials once it has loaded – they show while it loads and when it fails */\n.ui-avtc__img {\n  grid-area: 1 / 1;\n  display: block;\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  opacity: 0;\n  transition: opacity 180ms ease;\n}\n.ui-avtc[data-ready] .ui-avtc__img { opacity: 1; }\n.ui-avtc[data-ready] .ui-avtc__initials { visibility: hidden; }\n\n/* A hairline on top of pictures and agent tiles, so white edges don't melt into a white page */\n.ui-avtc[data-ready] .ui-avtc__face::after,\n.ui-avtc[data-kind=\"agent\"] .ui-avtc__face::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  box-shadow: inset 0 0 0 0.5px var(--ui-avtc-edge);\n  pointer-events: none;\n}\n\n/* Agent: its own pixel mark on a white tile */\n.ui-avtc[data-kind=\"agent\"] .ui-avtc__face { background: var(--ui-avtc-tile); }\n.ui-avtc__mark {\n  grid-area: 1 / 1;\n  display: grid;\n  grid-template-columns: repeat(3, var(--sq));\n  grid-auto-rows: var(--sq);\n  gap: var(--gap);\n}\n.ui-avtc__mark i {\n  border-radius: calc(var(--sq) / 4);\n  background: var(--ui-avtc-mark);\n  opacity: 0;\n  transition: opacity 240ms ease;\n}\n.ui-avtc__mark i[data-on] { opacity: 1; }\n/* Working: every square twinkles, in a diagonal wave */\n.ui-avtc[data-working] .ui-avtc__mark i { animation: ui-avtc-pixel 1.2s ease-in-out infinite both; }\n.ui-avtc[data-working] .ui-avtc__mark i:not([data-on]) { opacity: 0.16; }\n@keyframes ui-avtc-pixel {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0.2; }\n}\n\n/* Status: a dot on the corner, cut out of the avatar by a ring in the background color */\n.ui-avtc__status {\n  position: absolute;\n  inset-inline-end: 0;\n  bottom: 0;\n  width: var(--dot);\n  height: var(--dot);\n  border-radius: 50%;\n  background: var(--ui-avtc-online);\n  box-shadow: 0 0 0 1.5px var(--ui-avtc-ring);\n}\n.ui-avtc[data-shape=\"square\"] .ui-avtc__status { inset-inline-end: -2px; bottom: -2px; }\n.ui-avtc__status[data-status=\"away\"] { background: var(--ui-avtc-away); }\n.ui-avtc__status[data-status=\"busy\"] { background: var(--ui-avtc-busy); }\n\n/* Group: each one overlaps the one before by a quarter, a ring in the background color between them */\n.ui-avtc__group {\n  display: inline-flex;\n  align-items: center;\n  vertical-align: middle;\n  isolation: isolate;\n}\n.ui-avtc__group > .ui-avtc .ui-avtc__face { box-shadow: 0 0 0 2px var(--ui-avtc-ring); }\n.ui-avtc__group > :is(.ui-avtc, .ui-avtc__more) + :is(.ui-avtc, .ui-avtc__more) { margin-inline-start: calc(var(--s) / -4); }\n.ui-avtc__more {\n  display: inline-grid;\n  place-items: center;\n  box-sizing: border-box;\n  min-width: var(--s);\n  height: var(--s);\n  padding: 0 4px;\n  border-radius: 999px;\n  /* The gray tint laid over the ring color, so the face behind it doesn't show through */\n  background: linear-gradient(var(--ui-avtc-fill), var(--ui-avtc-fill)), var(--ui-avtc-ring);\n  box-shadow: 0 0 0 2px var(--ui-avtc-ring);\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: var(--fs);\n  font-weight: 500;\n  line-height: 1;\n  color: var(--ui-avtc-text);\n  white-space: nowrap;\n  font-variant-numeric: tabular-nums;\n  -webkit-font-smoothing: antialiased;\n}\n.ui-avtc__more[data-shape=\"square\"] { border-radius: calc(var(--s) / 4); }\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-avtc__img { transition: none; }\n  .ui-avtc__mark i { transition: none; }\n  .ui-avtc[data-working] .ui-avtc__mark i { animation: none; }\n  .ui-avtc[data-working] .ui-avtc__mark i:not([data-on]) { opacity: 0; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) :is(.ui-avtc, .ui-avtc__group) {\n  --ui-avtc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-avtc-text: rgba(255, 255, 255, 0.62);\n  --ui-avtc-fill: var(--aui-subtle, rgba(255, 255, 255, 0.08));\n  --ui-avtc-tile: var(--aui-surface, #1c1c1e);\n  --ui-avtc-edge: rgba(255, 255, 255, 0.1);\n  --ui-avtc-ring: var(--aui-surface, #1c1c1e);\n  --ui-avtc-mark: rgba(77, 141, 255, 0.85);\n  --ui-avtc-online: var(--aui-green, #7dcf9f);\n  --ui-avtc-away: var(--aui-amber, #e6c24d);\n  --ui-avtc-busy: var(--aui-red, #ff6b5f);\n  --ui-avtc-bg-l: 24%;\n  --ui-avtc-bg-s: 28%;\n  --ui-avtc-fg-l: 80%;\n  --ui-avtc-fg-s: 60%;\n}\n`;\n"}]}