{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar",
  "type": "registry:ui",
  "title": "Avatar",
  "description": "Photo that fades in once loaded, initials that never flash, presence that morphs.",
  "categories": [
    "display"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/avatar.tsx",
      "content": "\"use client\";\nimport { Avatar as BaseAvatar } from \"@base-ui/react/avatar\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { dur, ease } from \"@/lib/motion\";\n\nexport type AvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\nexport type Presence = \"online\" | \"away\" | \"busy\" | \"offline\";\ntype LoadStatus = \"idle\" | \"loading\" | \"loaded\" | \"error\";\n\nconst sizes: Record<AvatarSize, number> = { xs: 20, sm: 24, md: 32, lg: 40, xl: 56 };\n\n/** Pixel size for a named size, or the number itself. */\nexport const avatarPx = (size: AvatarSize | number) => (typeof size === \"number\" ? size : sizes[size]);\n\n/* -------------------------------------------------------------------------------------------------\n * Initials and tone\n * -----------------------------------------------------------------------------------------------*/\n\n/**\n * \"Maya Okafor\" → \"MO\", \"maya@acme.com\" → \"M\", \"Ana María de la Cruz\" → \"AC\", \"王小明\" → \"王\".\n * Parentheticals (\"(she/her)\") are dropped and emoji or accents are kept whole.\n */\nexport function getInitials(name: string, max: 1 | 2 = 2) {\n  const clean = name.replace(/\\(.*?\\)|\\[.*?\\]/g, \" \").split(\"@\")[0].trim();\n  const words = clean.split(/[\\s._-]+/).filter((w) => /[\\p{L}\\p{N}\\p{Extended_Pictographic}]/u.test(w));\n  if (!words.length) return \"\";\n  const first = (w: string) => Array.from(w.match(/[\\p{L}\\p{N}]|\\p{Extended_Pictographic}/u)?.[0] ?? w)[0] ?? \"\";\n  // Names that don't split into words (most CJK names) keep their first character only.\n  if (words.length === 1 || max === 1) return first(words[0]).toUpperCase();\n  return (first(words[0]) + first(words[words.length - 1])).toUpperCase();\n}\n\n// Five steps of the foreground over the surface, darkest to lightest. The text steps up with the\n// fill so every tone keeps its contrast in both themes. No hues: people are told apart by\n// initials and names, and color is reserved for meaning.\nconst tones = [\n  \"bg-fg/[0.06] text-fg-2\",\n  \"bg-fg/[0.09] text-fg-2\",\n  \"bg-fg/[0.12] text-fg\",\n  \"bg-fg/[0.16] text-fg\",\n  \"bg-fg/[0.21] text-fg\",\n] as const;\n\n/** A stable tone index for a person, so the same name always gets the same fill on every screen. */\nexport function avatarTone(key: string) {\n  // FNV-1a: tiny, fast and well spread for short strings.\n  let h = 0x811c9dc5;\n  for (let i = 0; i < key.length; i++) {\n    h ^= key.charCodeAt(i);\n    h = Math.imul(h, 0x01000193);\n  }\n  return (h >>> 0) % tones.length;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Presence\n * -----------------------------------------------------------------------------------------------*/\n\nexport const presenceLabel: Record<Presence, string> = { online: \"Online\", away: \"Away\", busy: \"Busy\", offline: \"Offline\" };\n\n// The dot is one path: a disc with a hole in it (even-odd fill). Every state is the same hole,\n// a stadium of a different size and place, so the shape can morph between any two of them.\n// Online has no hole, offline a round one, busy a bar, and away a large disc pushed up and to\n// the left, which the clip turns into a crescent. Each state has its own shape as well as its\n// color, so presence reads without color.\nconst holes: Record<Presence, [cx: number, cy: number, w: number, h: number]> = {\n  online: [8, 8, 0, 0],\n  offline: [8, 8, 7.4, 7.4],\n  busy: [8, 8, 9.4, 3.4],\n  away: [4.6, 4.6, 11.2, 11.2],\n};\n\nfunction dotPath([cx, cy, w, h]: (typeof holes)[Presence]) {\n  const r = Math.min(w, h) / 2;\n  const x = cx - w / 2;\n  const y = cy - h / 2;\n  const hw = w - 2 * r;\n  const hh = h - 2 * r;\n  const f = (n: number) => +n.toFixed(3);\n  return (\n    \"M0 8a8 8 0 1 0 16 0a8 8 0 1 0-16 0Z\" +\n    `M${f(x + r)} ${f(y)}h${f(hw)}a${f(r)} ${f(r)} 0 0 1 ${f(r)} ${f(r)}v${f(hh)}a${f(r)} ${f(r)} 0 0 1 ${f(-r)} ${f(r)}h${f(-hw)}a${f(r)} ${f(r)} 0 0 1 ${f(-r)} ${f(-r)}v${f(-hh)}a${f(r)} ${f(r)} 0 0 1 ${f(r)} ${f(-r)}Z`\n  );\n}\n\nexport type AvatarPresenceProps = Omit<React.ComponentProps<\"svg\">, \"ref\"> & {\n  status: Presence;\n  /** Diameter in px. */\n  size?: number;\n};\n\n/**\n * The presence dot on its own, for status pickers and lists. Its holes are real holes, so it sits\n * on any surface, hover washes and photos included.\n */\nexport function AvatarPresence({ status, size = 10, className, ...rest }: AvatarPresenceProps) {\n  const reduce = useReducedMotion();\n  const id = useId();\n  return (\n    <svg\n      aria-hidden\n      data-status={status}\n      width={size}\n      height={size}\n      viewBox=\"0 0 16 16\"\n      className={cn(\n        \"block shrink-0 overflow-visible transition-[color] duration-240 ease-out-expo\",\n        \"data-[status=online]:text-success data-[status=away]:text-warning data-[status=busy]:text-danger data-[status=offline]:text-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      <clipPath id={id}>\n        <circle cx=\"8\" cy=\"8\" r=\"8\" />\n      </clipPath>\n      <motion.path\n        clipPath={`url(#${id})`}\n        fillRule=\"evenodd\"\n        fill=\"currentColor\"\n        initial={false}\n        animate={{ d: dotPath(holes[status]) }}\n        transition={reduce ? { duration: 0 } : { duration: dur.indicator, ease: ease.out }}\n      />\n    </svg>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Avatar\n * -----------------------------------------------------------------------------------------------*/\n\nexport type AvatarProps = Omit<React.ComponentProps<\"span\">, \"children\"> & {\n  /** The person's or team's name. Drives the initials, the tone and the accessible name. */\n  name?: string;\n  src?: string;\n  srcSet?: string;\n  /** The accessible name. Defaults to the name; pass \"\" when the name is printed right beside it. */\n  alt?: string;\n  size?: AvatarSize | number;\n  /** Circles for people, rounded squares for teams, workspaces and bots. */\n  shape?: \"circle\" | \"square\";\n  /** Presence dot on the bottom-right edge, punched through the avatar so it works on any surface. */\n  status?: Presence;\n  /** Anything small for the top-right corner: a count, an app mark, a verified tick. */\n  badge?: React.ReactNode;\n  /** A cutout ring in `--avatar-ring` (the surface color, `--frame` by default) for placing on images or overlapping. */\n  ring?: boolean;\n  /** A foreground ring with a gap: selected account, the person speaking. */\n  highlighted?: boolean;\n  /** The person hasn't arrived yet. Draws a quiet placeholder of the same size. */\n  loading?: boolean;\n  /** Overrides the tone picked from the name, 0–4. */\n  tone?: number;\n  /** Milliseconds to wait for the image before showing initials, so fast loads never flash them. */\n  fallbackDelay?: number;\n  onLoadingStatusChange?: (status: LoadStatus) => void;\n};\n\nexport function Avatar({\n  name = \"\",\n  src,\n  srcSet,\n  alt,\n  size = \"md\",\n  shape = \"circle\",\n  status,\n  badge,\n  ring = false,\n  highlighted = false,\n  loading = false,\n  tone,\n  fallbackDelay = 500,\n  onLoadingStatusChange,\n  className,\n  style,\n  ...rest\n}: AvatarProps) {\n  const px = avatarPx(size);\n  const initials = getInitials(name, px < 24 ? 1 : 2);\n  const [load, setLoad] = useState<LoadStatus>(\"idle\");\n  const [instant, setInstant] = useState(false);\n  const sawLoading = useRef(false);\n  const startedAt = useRef(0);\n\n  const hasImage = !!(src || srcSet) && !loading;\n  // Initials: at once when there is no image or it failed; after the delay while it loads; gone once it has.\n  const show = !hasImage || load === \"error\" ? \"now\" : load === \"loading\" ? \"later\" : \"no\";\n\n  const radius = shape === \"square\" ? Math.max(4, Math.round(px * 0.25)) : px / 2;\n  const dot = Math.min(14, Math.max(6, Math.round(px * 0.28)));\n  // Sit the dot on the avatar's outline at 45°, wherever the curve is for this shape and size.\n  const inset = shape === \"square\" ? 0.293 * radius - dot / 2 : 0.1464 * px - dot / 2;\n  // The dot's ring is a real hole punched in the avatar, not a border in the page color, so it\n  // stays right on hover washes, selected rows and photos. Opaque stops only matter for their alpha.\n  const c = px - inset - dot / 2;\n  const hole = dot / 2 + (px >= 32 ? 2 : 1.5);\n  const cutout = status && !loading ? `radial-gradient(circle at ${c}px ${c}px, transparent ${hole}px, var(--fg) ${hole + 0.5}px)` : undefined;\n\n  const label = alt ?? (loading ? \"Loading\" : name);\n  // With the name printed beside it (alt=\"\"), the avatar still says the one thing only it shows: presence.\n  const spoken = status && !loading ? (label ? `${label}, ${presenceLabel[status].toLowerCase()}` : presenceLabel[status]) : label;\n  const decorative = !spoken;\n\n  return (\n    <span\n      role={decorative ? undefined : \"img\"}\n      aria-label={decorative ? undefined : spoken}\n      aria-hidden={decorative || undefined}\n      aria-busy={loading || undefined}\n      data-size={typeof size === \"string\" ? size : undefined}\n      data-shape={shape}\n      data-status={status}\n      data-loading={loading || undefined}\n      style={{ width: px, height: px, \"--avatar-radius\": `${radius}px`, ...style } as React.CSSProperties}\n      className={cn(\"relative inline-flex shrink-0 select-none align-middle\", className)}\n      {...rest}\n    >\n      <BaseAvatar.Root\n        style={{ maskImage: cutout, WebkitMaskImage: cutout }}\n        className={cn(\n          \"relative isolate flex size-full items-center justify-center overflow-hidden rounded-(--avatar-radius)\",\n          \"transition-[background-color,box-shadow] duration-200\",\n          loading ? \"animate-pulse-soft bg-fg/[0.06]\" : tones[tone ?? avatarTone(name || \"?\")],\n          // A hairline on top of everything, so pale photos keep an edge on pale surfaces.\n          \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-1 after:ring-inset after:ring-fg/[0.08]\",\n          ring && \"shadow-[0_0_0_2px_var(--avatar-ring,var(--frame))]\",\n        )}\n      >\n        {!loading && (\n          <span\n            aria-hidden\n            data-show={show}\n            className={cn(\n              \"absolute inset-0 grid place-items-center font-medium leading-none tracking-[0.02em]\",\n              \"transition-opacity duration-200 ease-out\",\n              \"data-[show=no]:opacity-0 data-[show=later]:delay-(--avatar-delay)\",\n            )}\n            style={{ fontSize: Math.round(px * (initials.length > 1 ? 0.36 : 0.42) * 2) / 2, \"--avatar-delay\": `${fallbackDelay}ms` } as React.CSSProperties}\n          >\n            {initials || <PersonGlyph size={Math.round(px * 0.56)} />}\n          </span>\n        )}\n        {hasImage && (\n          <BaseAvatar.Image\n            src={src}\n            srcSet={srcSet}\n            alt=\"\"\n            width={px}\n            height={px}\n            draggable={false}\n            data-instant={instant || undefined}\n            onLoadingStatusChange={(next) => {\n              // Cached images report \"loaded\" at once, or within a frame of \"loading\" from the disk cache. Those appear\n              // without the fade, so revisiting a list doesn't replay it on every face.\n              if (next === \"loading\") {\n                sawLoading.current = true;\n                startedAt.current = performance.now();\n              }\n              if (next === \"loaded\") {\n                setInstant(!sawLoading.current || performance.now() - startedAt.current < 20);\n                sawLoading.current = false;\n              }\n              setLoad(next);\n              onLoadingStatusChange?.(next);\n            }}\n            className={cn(\n              \"absolute inset-0 size-full object-cover\",\n              \"transition-[opacity,filter,scale] duration-300 ease-out-expo\",\n              \"data-starting-style:scale-[1.06] data-starting-style:opacity-0 data-starting-style:blur-[3px]\",\n              \"data-ending-style:opacity-0 data-ending-style:duration-150\",\n              \"data-instant:transition-none\",\n            )}\n          />\n        )}\n      </BaseAvatar.Root>\n\n      {/* The highlight ring grows in from just outside the avatar. */}\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute -inset-[3px] rounded-[calc(var(--avatar-radius)+3px)] border-[1.5px] border-fg\",\n          \"transition-[opacity,scale] duration-200 ease-out-expo\",\n          highlighted ? \"scale-100 opacity-100\" : \"scale-[1.08] opacity-0\",\n        )}\n      />\n\n      {status && !loading && <AvatarPresence status={status} size={dot} className=\"absolute\" style={{ right: inset, bottom: inset }} />}\n\n      {badge && (\n        <span aria-hidden className=\"absolute -right-1 -top-1 flex\">\n          {badge}\n        </span>\n      )}\n    </span>\n  );\n}\n\nfunction PersonGlyph({ size }: { size: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" aria-hidden className=\"opacity-80\">\n      <circle cx=\"8\" cy=\"5.75\" r=\"2.75\" />\n      <path d=\"M2.75 14c.6-2.6 2.7-4.25 5.25-4.25S12.65 11.4 13.25 14\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/avatar"
}
