{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar",
  "type": "registry:ui",
  "title": "Avatar",
  "description": "A round mark for a person, a client or a company, in three sizes: a photo, or initials on the tint of one of the five data hues, picked from the name so the same name always gets the same color. A group overlaps them with a ring in the surface they sit on, and counts the rest in a +N pill.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/avatar.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/avatar 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Avatar: a round mark for a person, a client or a company, and AvatarGroup, a stack of them.\n *\n * - Shape (components.md, Avatar): a circle at rounded-litt-pill, one of the three things the pill is for.\n *   Three sizes: sm 24 (beside a tag, dense rows), md 32 (list rows, the default) and lg 44\n *   (a profile header). Initials are the sans at the text weight (400) at every size: caption at sm,\n *   small at md, body at lg, trimmed to the cap height so they sit in the middle of the circle.\n * - Content: a photo (`src`, object-cover) with an inset line hairline over it, so a white photo\n *   keeps its edge. With no photo, or when it fails to load, the initials of `name`: the first\n *   letter of the first two words, or one letter for one word. Leading characters that are not\n *   letters are skipped, so \"@stripe.com\" gives S.\n * - Art (`look`): instead of initials, a soft field drawn from the name under static grain, like a\n *   small photograph. `fluid` is warped noise from ink through the hue to cream; `film` is diagonal\n *   bands, pale, ink and the hue, with a streak of light on the hue's edge; `thermal` is a heat map\n *   smeared top to bottom, the hue's partner (warm against cool) for the cool parts and the hue for\n *   the hot ones. A seeded hash of the name places everything, so the same name draws the same mark\n *   every time and needs no state. The colors are litt's tokens, read from the light theme in both\n *   themes, so the picture is the same on a light and a dark page, as a photo is. It is drawn once per\n *   device pixel into a canvas (the grain is one screen pixel) and never moves (R2). Until it draws,\n *   the hue's tint shows. The initials stay in the box, invisible and hidden from assistive tech, so\n *   the avatar keeps the same text baseline either way.\n * - Color (R7, identity is data): the hue's tint as the fill, the initials in the hue's text\n *   tone, and an inset hairline of the hue at 24% under the top highlight. That is Tag's hue look\n *   exactly. `hue` sets it. Without one, a stable hash of the name picks one of the five in\n *   order, so the same name gets the same hue everywhere, with no state to keep. The hue is a\n *   nudge to the eye and not a key: two names can share one, so the name is always the identifier.\n *   `neutral` is a surface-2 fill, fg-muted initials and a line hairline.\n * - Semantics: initials are a `role=\"img\"` named by the name. A photo is an <img> with the name as\n *   its alt. When the name is written beside the avatar, `decorative` hides it from assistive\n *   tech, so the name is not read twice.\n * - AvatarGroup: a labelled group that shows the first `max` and then a \"+N\" pill. The avatars\n *   overlap by a quarter of their size, the first on top, each with a 2px ring in the surface the\n *   group sits on, drawn as a zero-offset spread shadow (never a border, which would move the box).\n *   The next avatar and its ring cover the left of each one after the first, so their initials are\n *   centered in the part that shows (a --cover padding), and a letter is never cut.\n * - Static: no hover, no focus stop, no motion. Wrapping an avatar in a link or a button is the\n *   caller's job.\n */\nimport { Children, createContext, isValidElement, useContext, useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from \"react\";\n\n/** The five data hues in their fixed order (R7), and neutral for a mark with no identity color. */\nexport type AvatarHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\" | \"neutral\";\n\n/** What shows with no photo: the initials, or a picture drawn from the name. */\nexport type AvatarLook = \"initials\" | \"fluid\" | \"film\" | \"thermal\";\n\ntype Size = \"sm\" | \"md\" | \"lg\";\ntype Ring = \"canvas\" | \"surface-1\" | \"surface-2\";\n\n/* Hue classes are literal strings, because Tailwind only generates what it can read. */\nconst FILL: Record<AvatarHue, string> = {\n  blue: \"bg-litt-data-blue-tint text-litt-data-blue-text\",\n  amber: \"bg-litt-data-amber-tint text-litt-data-amber-text\",\n  teal: \"bg-litt-data-teal-tint text-litt-data-teal-text\",\n  violet: \"bg-litt-data-violet-tint text-litt-data-violet-text\",\n  rose: \"bg-litt-data-rose-tint text-litt-data-rose-text\",\n  neutral: \"bg-litt-surface-2 text-litt-fg-muted in-[[data-material=flat]]:bg-litt-surface-3\",\n};\n\n/* The edge sits on its own layer above the fill and any photo, so the ring a group draws on the\n   root never competes with it for the one box-shadow. */\nconst EDGE: Record<AvatarHue, string> = {\n  blue: \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--litt-data-blue)_24%,transparent),var(--litt-highlight)]\",\n  amber: \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--litt-data-amber)_24%,transparent),var(--litt-highlight)]\",\n  teal: \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--litt-data-teal)_24%,transparent),var(--litt-highlight)]\",\n  violet: \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--litt-data-violet)_24%,transparent),var(--litt-highlight)]\",\n  rose: \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--litt-data-rose)_24%,transparent),var(--litt-highlight)]\",\n  neutral: \"shadow-[inset_0_0_0_1px_var(--litt-line)]\",\n};\nconst PHOTO_EDGE = \"shadow-[inset_0_0_0_1px_var(--litt-line)]\";\n\nconst ORDER: AvatarHue[] = [\"blue\", \"amber\", \"teal\", \"violet\", \"rose\"];\n\nconst BOX: Record<Size, string> = { sm: \"size-6\", md: \"size-8\", lg: \"size-11\" };\nconst BOX_MIN: Record<Size, string> = { sm: \"h-6 min-w-6\", md: \"h-8 min-w-8\", lg: \"h-11 min-w-11\" };\nconst TEXT: Record<Size, string> = { sm: \"text-litt-caption\", md: \"text-litt-small\", lg: \"text-litt-body\" };\n/* A quarter of the size: 6, 8 and 11px. The next avatar and its 2px ring cover that much of the left of this\n   one, so --cover says how much (8, 10 and 13px) and the initials are centered in what is left. */\nconst LAP: Record<Size, string> = {\n  sm: \"-ml-1.5 [--cover:8px]\",\n  md: \"-ml-2 [--cover:10px]\",\n  lg: \"-ml-[11px] [--cover:13px]\",\n};\nconst UNCOVER = \"pl-[var(--cover,0px)]\";\n\nconst RING: Record<Ring, string> = {\n  canvas: \"shadow-[0_0_0_2px_var(--litt-canvas)]\",\n  \"surface-1\": \"shadow-[0_0_0_2px_var(--litt-surface-1)]\",\n  \"surface-2\": \"shadow-[0_0_0_2px_var(--litt-surface-2)]\",\n};\n\n/** A 32-bit hash of a name: FNV-1a with a final mix. Case and outer spaces do not matter. */\nfunction hashOf(name: string): number {\n  let h = 2166136261;\n  for (const ch of name.trim().toLowerCase()) {\n    h ^= ch.codePointAt(0) ?? 0;\n    h = Math.imul(h, 16777619);\n  }\n  h ^= h >>> 15;\n  h = Math.imul(h, 2246822507);\n  h ^= h >>> 13;\n  return h >>> 0;\n}\n\n/** A stable hue for a name, so the five hues come out about evenly. */\nfunction hueOf(name: string): AvatarHue {\n  return ORDER[hashOf(name) % ORDER.length];\n}\n\n/* ------------------------------------------------------------------ the art */\n\ntype Lab = [number, number, number];\ntype Palette = { ink: Lab; cream: Lab; hue: Record<AvatarHue, Lab> };\ntype Stop = [number, Lab];\n\n/* A contrasting partner for each hue, warm against cool, for thermal's cool parts. */\nconst PARTNER: Record<AvatarHue, AvatarHue> = { blue: \"amber\", amber: \"violet\", teal: \"rose\", violet: \"teal\", rose: \"blue\", neutral: \"neutral\" };\n\n/** mulberry32: a small seeded generator, so a name always places its art the same way. */\nfunction seeded(seed: number): () => number {\n  return () => {\n    seed = (seed + 0x6d2b79f5) | 0;\n    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\nfunction hash2(i: number, j: number, s: number): number {\n  let h = (Math.imul(i, 374761393) + Math.imul(j, 668265263) + Math.imul(s, 362437)) | 0;\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  h ^= h >>> 16;\n  return (h >>> 0) / 4294967296;\n}\n/** Value noise, smoothly interpolated. */\nfunction noise(x: number, y: number, s: number): number {\n  const xi = Math.floor(x);\n  const yi = Math.floor(y);\n  const xf = x - xi;\n  const yf = y - yi;\n  const u = xf * xf * (3 - 2 * xf);\n  const v = yf * yf * (3 - 2 * yf);\n  const a = hash2(xi, yi, s);\n  const b = hash2(xi + 1, yi, s);\n  const c = hash2(xi, yi + 1, s);\n  const d = hash2(xi + 1, yi + 1, s);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\n/** Four octaves of noise, 0 to 1. */\nfunction fbm(x: number, y: number, s: number): number {\n  let t = 0;\n  let amp = 0.5;\n  let f = 1;\n  for (let o = 0; o < 4; o++) {\n    t += amp * noise(x * f, y * f, s + o * 101);\n    f *= 2;\n    amp *= 0.5;\n  }\n  return t / 0.9375;\n}\nconst clamp01 = (x: number) => Math.min(1, Math.max(0, x));\nconst smooth = (a: number, b: number, x: number) => {\n  const t = clamp01((x - a) / (b - a));\n  return t * t * (3 - 2 * t);\n};\n\n/* sRGB and OKLab, so a blend between two colors stays clean instead of going grey in the middle. */\nconst toLin = (c: number) => ((c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);\nconst toSrgb = (c: number) => 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);\nfunction labOf([r, g, b]: number[]): Lab {\n  r = toLin(r);\n  g = toLin(g);\n  b = toLin(b);\n  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);\n  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);\n  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);\n  return [\n    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,\n    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,\n    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,\n  ];\n}\nfunction rgbOfLab([L, A, B]: Lab): Lab {\n  const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;\n  const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;\n  const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;\n  return [\n    toSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),\n    toSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),\n    toSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),\n  ];\n}\nconst mixLab = (a: Lab, b: Lab, t: number): Lab => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];\n/** A gradient map: the color at t along the stops. */\nfunction ramp(stops: Stop[], t: number): Lab {\n  if (t <= stops[0][0]) return stops[0][1];\n  for (let k = 1; k < stops.length; k++) {\n    if (t <= stops[k][0]) return mixLab(stops[k - 1][1], stops[k][1], (t - stops[k - 1][0]) / (stops[k][0] - stops[k - 1][0]));\n  }\n  return stops[stops.length - 1][1];\n}\n\n/* The tokens the art is made of. Read once, from the light theme, so the picture is the same in both themes. */\nlet palette: Palette | null = null;\nfunction paletteOf(): Palette {\n  if (palette) return palette;\n  const probe = document.createElement(\"span\");\n  probe.setAttribute(\"data-theme\", \"light\");\n  probe.style.cssText = \"position:absolute;width:0;height:0;overflow:hidden\";\n  document.body.appendChild(probe);\n  /* Any CSS color the token holds (hex, rgb, oklch) comes out as sRGB through a one-pixel canvas. */\n  const pixel = document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  const read = (token: string): Lab => {\n    probe.style.color = `var(${token})`;\n    if (!pixel) return labOf([0, 0, 0]);\n    pixel.clearRect(0, 0, 1, 1);\n    pixel.fillStyle = getComputedStyle(probe).color;\n    pixel.fillRect(0, 0, 1, 1);\n    const [r, g, b] = pixel.getImageData(0, 0, 1, 1).data;\n    return labOf([r, g, b]);\n  };\n  const amber = read(\"--litt-data-amber\");\n  const hue: Record<AvatarHue, Lab> = {\n    blue: read(\"--litt-data-blue\"),\n    amber,\n    teal: read(\"--litt-data-teal\"),\n    violet: read(\"--litt-data-violet\"),\n    rose: read(\"--litt-data-rose\"),\n    neutral: read(\"--litt-line-control\"),\n  };\n  palette = { ink: read(\"--litt-fg\"), cream: mixLab(read(\"--litt-canvas\"), amber, 0.18), hue };\n  probe.remove();\n  return palette;\n}\n\ntype Art = {\n  /** How strong the grain is, in OKLab lightness. */\n  grain: number;\n  /** The look for one name: its numbers, placed by the name's generator. */\n  setup: (hue: AvatarHue, P: Palette, rand: () => number) => (u: number, v: number) => Lab;\n};\n\n/**\n * A field mapped through a gradient, balanced per name: the field is sampled inside the circle first, and its own 8th,\n * 50th and 92nd percentiles are moved to `anchors` on the gradient. So no seed comes out all ink or all cream: every\n * mark spans the gradient, with its middle where the anchors put it.\n */\nfunction balanced(field: (u: number, v: number) => number, stops: Stop[], anchors: [number, number, number]): (u: number, v: number) => Lab {\n  const n = 24;\n  const values: number[] = [];\n  for (let j = 0; j < n; j++) {\n    for (let i = 0; i < n; i++) {\n      const x = (i + 0.5) / n;\n      const y = (j + 0.5) / n;\n      if ((x - 0.5) ** 2 + (y - 0.5) ** 2 <= 0.25) values.push(field(x, y));\n    }\n  }\n  values.sort((a, b) => a - b);\n  const at = (q: number) => values[Math.min(values.length - 1, Math.floor(q * values.length))];\n  const lo = at(0.08);\n  const mid = at(0.5);\n  const hi = at(0.92);\n  const [a, b, c] = anchors;\n  const place = (t: number) => (t < mid ? a + ((b - a) * (t - lo)) / Math.max(1e-6, mid - lo) : b + ((c - b) * (t - mid)) / Math.max(1e-6, hi - mid));\n  return (u, v) => ramp(stops, clamp01(place(field(u, v))));\n}\n\nconst ART: Record<Exclude<AvatarLook, \"initials\">, Art> = {\n  /* Warped noise from ink through the hue to cream. One hue and the neutrals; the hue holds the middle. */\n  fluid: {\n    grain: 0.09,\n    setup: (h, P, r) => {\n      const ox = r() * 50;\n      const oy = r() * 50;\n      const s = Math.floor(r() * 1e6);\n      const stops: Stop[] = [[0, P.ink], [0.3, mixLab(P.hue[h], P.ink, 0.6)], [0.52, P.hue[h]], [0.75, mixLab(P.hue[h], P.cream, 0.6)], [1, P.cream]];\n      return balanced(\n        (u, v) => {\n          const x = u * 1.7 + ox;\n          const y = v * 1.7 + oy;\n          const wx = fbm(x, y, s);\n          const wy = fbm(x + 5.2, y + 1.3, s + 7);\n          return fbm(x + 2.2 * wx, y + 2.2 * wy, s + 13);\n        },\n        stops,\n        [0.08, 0.52, 0.92],\n      );\n    },\n  },\n  /* Diagonal bands: a pale corner at the top, where the light is, then ink, then the hue with a streak of light along\n     its edge, under a soft vignette. The name sets the angle, which side the pale corner is on and where the bands fall. */\n  film: {\n    grain: 0.07,\n    setup: (h, P, r) => {\n      const a = ((-0.75 + r() * 0.5) * Math.PI) / 2;\n      const cos = Math.cos(a);\n      const sin = Math.sin(a);\n      const edge = -0.08 + r() * 0.24;\n      const paleAt = edge - 0.22 - r() * 0.12;\n      const s = Math.floor(r() * 1e6);\n      const flip = r() < 0.5;\n      const pale = mixLab(P.hue[h], P.cream, 0.6);\n      return (u0, v) => {\n        const u = flip ? 1 - u0 : u0;\n        const d = (u - 0.5) * sin + (v - 0.5) * cos + 0.05 * (fbm(u * 3, v * 3, s) - 0.5);\n        const hue = mixLab(P.hue[h], P.ink, 0.15 * smooth(0, 0.5, d - edge));\n        let c = mixLab(pale, P.ink, smooth(paleAt - 0.1, paleAt + 0.1, d));\n        c = mixLab(c, hue, smooth(edge - 0.08, edge + 0.08, d));\n        const glint = Math.exp(-(((d - edge - 0.06) / 0.025) ** 2)) * (0.4 + 0.6 * fbm(u * 6, v * 6, s + 9));\n        c = mixLab(c, P.cream, 0.75 * glint);\n        const vignette = 1 - 0.25 * ((u - 0.5) ** 2 + (v - 0.5) ** 2);\n        return [c[0] * vignette, c[1], c[2]];\n      };\n    },\n  },\n  /* A heat map smeared top to bottom: cream at the cold edges, the partner hue for the cool parts, the hue for the hot\n     ones. Balanced, so both hues show in every mark. */\n  thermal: {\n    grain: 0.04,\n    setup: (h, P, r) => {\n      const ox = r() * 50;\n      const oy = r() * 50;\n      const s = Math.floor(r() * 1e6);\n      const cool = P.hue[PARTNER[h]];\n      const hot = P.hue[h];\n      const stops: Stop[] = [\n        [0, P.cream],\n        [0.2, mixLab(cool, P.cream, 0.4)],\n        [0.38, cool],\n        [0.5, mixLab(cool, P.ink, 0.35)],\n        [0.6, hot],\n        [0.86, mixLab(hot, P.cream, 0.2)],\n        [1, mixLab(hot, P.cream, 0.45)],\n      ];\n      return balanced(\n        (u, v) => {\n          const x = u * 2.6 + ox;\n          const y = v * 0.55 + oy;\n          const warp = fbm(x * 0.5, y * 2, s + 3);\n          return fbm(x + 1.4 * warp, y, s);\n        },\n        stops,\n        [0.14, 0.5, 0.9],\n      );\n    },\n  },\n};\n\n/* Drawn pictures, by look, hue, name and pixel size, so a list that shows a name twice draws it once. */\nconst drawn = new Map<string, ImageData>();\n\n/** The picture for a name, `w` device pixels across. */\nfunction pictureOf(look: Exclude<AvatarLook, \"initials\">, hue: AvatarHue, name: string, w: number, ctx: CanvasRenderingContext2D): ImageData {\n  const key = `${look}|${hue}|${w}|${name.trim().toLowerCase()}`;\n  const hit = drawn.get(key);\n  if (hit) return hit;\n  const art = ART[look];\n  const shade = art.setup(hue, paletteOf(), seeded(hashOf(name) ^ 0x51ed270b));\n  const img = ctx.createImageData(w, w);\n  const grainSeed = hashOf(name) & 0xffff;\n  for (let j = 0; j < w; j++) {\n    for (let i = 0; i < w; i++) {\n      const lab = shade((i + 0.5) / w, (j + 0.5) / w);\n      /* Grain: one screen pixel, static, the same for a name every time. */\n      const g = (hash2(i, j, grainSeed) - 0.5) * 2 * art.grain;\n      const [r, gg, b] = rgbOfLab([lab[0] + g, lab[1], lab[2]]);\n      const o = (j * w + i) * 4;\n      img.data[o] = r;\n      img.data[o + 1] = gg;\n      img.data[o + 2] = b;\n      img.data[o + 3] = 255;\n    }\n  }\n  if (drawn.size > 256) drawn.clear();\n  drawn.set(key, img);\n  return img;\n}\n\n/* The box in CSS pixels, for the canvas. */\nconst PX: Record<Size, number> = { sm: 24, md: 32, lg: 44 };\n\n/** Paints the art into its canvas, once per name, look, hue and size. */\nfunction AvatarArt({ name, look, hue, size }: { name: string; look: Exclude<AvatarLook, \"initials\">; hue: AvatarHue; size: Size }) {\n  const ref = useRef<HTMLCanvasElement>(null);\n  useLayoutEffect(() => {\n    const canvas = ref.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n    const w = PX[size] * Math.max(1, Math.round(window.devicePixelRatio || 1));\n    canvas.width = w;\n    canvas.height = w;\n    ctx.putImageData(pictureOf(look, hue, name, w, ctx), 0, 0);\n  }, [name, look, hue, size]);\n  return <canvas ref={ref} aria-hidden=\"true\" className=\"absolute inset-0 size-full\" />;\n}\n\n/** The first letter of the first two words that have one. \"@stripe.com\" is S and \"Ines Duarte\" is ID. */\nfunction initialsOf(name: string): string {\n  const out: string[] = [];\n  for (const word of name.split(/\\s+/)) {\n    const letter = /\\p{L}/u.exec(word)?.[0];\n    if (letter) out.push(letter.toLocaleUpperCase());\n    if (out.length === 2) break;\n  }\n  return out.join(\"\");\n}\n\n/* A group sets the size of every avatar in it, so a child cannot disagree. */\nconst GroupContext = createContext<{ size: Size } | null>(null);\n\nexport type AvatarProps = Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> & {\n  /** Who or what it is, such as \"Ines Duarte\" or \"Stripe\". It makes the initials and the hue, and it names the avatar for a screen reader. */\n  name: string;\n  /** A photo or a logo. If it is missing or fails to load, the initials show instead. */\n  src?: string;\n  /** Sm is 24, md is 32 and lg is 44. Inside an AvatarGroup the group's size wins. */\n  size?: Size;\n  /** One of the five data hues, or neutral. Leave it out and the name picks one, the same one every time. */\n  hue?: AvatarHue;\n  /** What shows with no photo: the initials, or a picture drawn from the name in its hue, the same one every time. Fluid and film use the hue alone; thermal adds its partner. */\n  look?: AvatarLook;\n  /** Set it when the name is written beside the avatar: the avatar is hidden from screen readers so the name is not read twice. */\n  decorative?: boolean;\n  /** Extra classes for the avatar. */\n  className?: string;\n};\n\n/** A round mark for a person, a client or a company: a photo, or initials (or a picture) in a data hue picked from the name. */\nexport function Avatar({ name, src, size = \"md\", hue, look = \"initials\", decorative = false, className = \"\", ...rest }: AvatarProps) {\n  const group = useContext(GroupContext);\n  const s = group?.size ?? size;\n  const tone = hue ?? hueOf(name);\n  /* A photo that failed stays failed until the address changes. */\n  const [failed, setFailed] = useState<string | undefined>(undefined);\n  const photo = src !== undefined && src !== \"\" && failed !== src;\n  const named = !photo && !decorative;\n  return (\n    <span\n      role={named ? \"img\" : undefined}\n      aria-label={named ? name : undefined}\n      aria-hidden={decorative || undefined}\n      className={`relative inline-grid shrink-0 select-none place-items-center overflow-hidden rounded-litt-pill ${BOX[s]} ${FILL[tone]} ${className}`}\n      {...rest}\n    >\n      {photo ? (\n        <img\n          src={src}\n          alt={decorative ? \"\" : name}\n          draggable={false}\n          className=\"absolute inset-0 size-full object-cover\"\n          onError={() => setFailed(src)}\n        />\n      ) : look !== \"initials\" ? (\n        <AvatarArt name={name} look={look} hue={tone} size={s} />\n      ) : null}\n      {/* The initials. Under a picture they are invisible but still set the avatar's text baseline, so a row that aligns\n          by baseline lines up exactly as it does with initials showing. */}\n      {!photo && (\n        <span\n          aria-hidden={look !== \"initials\" || undefined}\n          className={`litt-trim font-normal ${look !== \"initials\" ? \"invisible\" : \"\"} ${UNCOVER} ${TEXT[s]}`}\n        >\n          {initialsOf(name)}\n        </span>\n      )}\n      <span aria-hidden=\"true\" className={`pointer-events-none absolute inset-0 rounded-litt-pill in-[[data-material=flat]]:shadow-none ${photo ? PHOTO_EDGE : EDGE[tone]}`} />\n    </span>\n  );\n}\n\nexport type AvatarGroupProps = {\n  /** What the group is, which a screen reader reads first: \"Project team, 26 people\". */\n  label: string;\n  /** The Avatars, first in front. Pass only the first few and set `total` when there are more. */\n  children: ReactNode;\n  /** Sm is 24, md is 32 and lg is 44. Every avatar in the group takes it. */\n  size?: Size;\n  /** How many avatars show before the \"+N\" pill. */\n  max?: number;\n  /** How many people there are in all, for when you pass only the first few. Leave it out to count the children. */\n  total?: number;\n  /** The surface the group sits on, so the 2px ring that separates the avatars matches it. */\n  ring?: Ring;\n  /** Extra classes for the group. */\n  className?: string;\n};\n\n/** A row of overlapping Avatars, the first on top, that ends in a \"+N\" pill when there are more than `max`. */\nexport function AvatarGroup({ label, children, size = \"md\", max = 4, total, ring = \"canvas\", className = \"\" }: AvatarGroupProps) {\n  const all = Children.toArray(children).filter(isValidElement);\n  const shown = all.slice(0, Math.max(0, max));\n  const more = Math.max(total ?? 0, all.length) - shown.length;\n  const wrap = `relative inline-flex shrink-0 rounded-litt-pill ${RING[ring]}`;\n  return (\n    <GroupContext.Provider value={{ size }}>\n      {/* isolate keeps the stack's z-index inside the group, so it never rises above a sticky header. */}\n      <span role=\"group\" aria-label={label} className={`isolate inline-flex items-center ${className}`}>\n        {shown.map((child, i) => (\n          <span key={child.key ?? i} style={{ zIndex: shown.length - i }} className={`${wrap} ${i > 0 ? LAP[size] : \"\"}`}>\n            {child}\n          </span>\n        ))}\n        {more > 0 && (\n          <span className={`${wrap} ${shown.length > 0 ? LAP[size] : \"\"}`}>\n            <span\n              role=\"img\"\n              aria-label={`${more} more`}\n              className={`inline-grid select-none place-items-center rounded-litt-pill bg-litt-surface-2 px-1 text-litt-caption tabular-nums font-normal text-litt-fg-muted shadow-[inset_0_0_0_1px_var(--litt-line)] in-[[data-material=flat]]:bg-litt-surface-3 in-[[data-material=flat]]:shadow-none ${BOX_MIN[size]}`}\n            >\n              <span className={`litt-trim ${UNCOVER}`}>+{more}</span>\n            </span>\n          </span>\n        )}\n      </span>\n    </GroupContext.Provider>\n  );\n}\n",
      "target": "components/litt/avatar.tsx"
    }
  ],
  "docs": "Import Avatar and AvatarGroup from components/litt/avatar. Pass a name: the initials and the hue come from it unless you set a hue. Set decorative when the name is written beside the avatar. Give AvatarGroup a label, the first few avatars and a total, and match its ring to the surface it sits on.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
