{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"avatar","type":"registry:ui","title":"Avatar","description":"A person or account as an image that shows a skeleton while it loads and falls back to one or two initials on a colour fixed by the name, as a circle or a rounded square","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["data-display"],"registryDependencies":["https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/avatar/Avatar.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { HTMLAttributes } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { accessibleName, avatarToneClass, initialsOf, resolveAvatarStatus } from \"./avatar-core\";\n\nexport type AvatarSize = \"sm\" | \"md\" | \"lg\";\nexport type AvatarShape = \"circle\" | \"square\";\n\n/**\n * `AvatarProps` plus every native attribute and listener of the `<span>` it\n * renders. Own props win.\n */\nexport interface AvatarProps extends Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> {\n\t/** Image URL. While it loads a skeleton shows; if it fails the initials show. */\n\tsrc?: string;\n\t/**\n\t * The accessible name, and the one required decision whenever `src` is set:\n\t * describe the image, or pass `\"\"` to mark the avatar decorative (beside\n\t * a visible name that already says who it is). Falls back to `name`.\n\t */\n\talt?: string;\n\t/**\n\t * Who this is. Gives one or two initials for the fallback and a colour that\n\t * is always the same for the same name.\n\t */\n\tname?: string;\n\t/** Diameter: 28, 36 or 64px. */\n\tsize?: AvatarSize;\n\t/** Fully round, or a rounded square. */\n\tshape?: AvatarShape;\n\t/** Additional CSS classes. */\n\tclassName?: string;\n}\n\nconst SIZE_CLASSES: Record<AvatarSize, string> = {\n\tsm: \"size-7 text-[0.65rem]\",\n\tmd: \"size-9 text-xs\",\n\tlg: \"size-16 text-lg\",\n};\n\nconst SHAPE_CLASSES: Record<AvatarShape, string> = {\n\tcircle: \"rounded-full\",\n\tsquare: \"rounded-lg\",\n};\n\nexport function Avatar({\n\tsrc,\n\talt,\n\tname,\n\tsize = \"md\",\n\tshape = \"circle\",\n\tclassName,\n\t...restProps\n}: AvatarProps) {\n\t// The last source that loaded and the last that failed, not a status flag: a\n\t// new `src` is \"loading\" again with nothing to reset, and the server render\n\t// and the first client render agree.\n\tconst [loadedSrc, setLoadedSrc] = useState<string | null>(null);\n\tconst [failedSrc, setFailedSrc] = useState<string | null>(null);\n\tconst imgRef = useRef<HTMLImageElement | null>(null);\n\n\tconst status = resolveAvatarStatus(src, loadedSrc, failedSrc);\n\tconst label = accessibleName(alt, name);\n\tconst initials = initialsOf(name);\n\n\t// An image that finished before this component hydrated has already fired\n\t// its load or error event, so the handlers below never hear about it. Ask\n\t// the element once it exists.\n\tuseEffect(() => {\n\t\tconst el = imgRef.current;\n\t\tif (!el || !src || !el.complete) return;\n\t\tif (el.naturalWidth > 0) setLoadedSrc(src);\n\t\telse setFailedSrc(src);\n\t}, [src]);\n\n\treturn (\n\t\t<span\n\t\t\t{...restProps}\n\t\t\tclassName={cn(\n\t\t\t\t\"relative inline-flex shrink-0 items-center justify-center overflow-hidden font-semibold select-none\",\n\t\t\t\tSIZE_CLASSES[size],\n\t\t\t\tSHAPE_CLASSES[shape],\n\t\t\t\tstatus === \"fallback\" ? avatarToneClass(name) : \"bg-muted text-muted-foreground\",\n\t\t\t\tclassName\n\t\t\t)}\n\t\t\trole={label ? \"img\" : restProps.role}\n\t\t\taria-label={label ?? restProps[\"aria-label\"]}\n\t\t\taria-hidden={label ? undefined : true}\n\t\t\tdata-size={size}\n\t\t\tdata-shape={shape}\n\t\t\tdata-status={status}\n\t\t>\n\t\t\t{src && status !== \"fallback\" ? (\n\t\t\t\t<img\n\t\t\t\t\tref={imgRef}\n\t\t\t\t\tsrc={src}\n\t\t\t\t\talt=\"\"\n\t\t\t\t\tdecoding=\"async\"\n\t\t\t\t\tdraggable={false}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"absolute inset-0 size-full object-cover motion-safe:transition-opacity motion-safe:duration-200\",\n\t\t\t\t\t\tstatus === \"loaded\" ? \"opacity-100\" : \"opacity-0\"\n\t\t\t\t\t)}\n\t\t\t\t\tonLoad={() => setLoadedSrc(src)}\n\t\t\t\t\tonError={() => setFailedSrc(src)}\n\t\t\t\t/>\n\t\t\t) : null}\n\t\t\t{status === \"loading\" ? (\n\t\t\t\t<span\n\t\t\t\t\tdata-slot=\"skeleton\"\n\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\tclassName=\"bg-foreground/10 absolute inset-0 motion-safe:animate-pulse\"\n\t\t\t\t/>\n\t\t\t) : status === \"fallback\" ? (\n\t\t\t\tinitials ? (\n\t\t\t\t\t<span data-slot=\"initials\" aria-hidden=\"true\">\n\t\t\t\t\t\t{initials}\n\t\t\t\t\t</span>\n\t\t\t\t) : (\n\t\t\t\t\t<svg\n\t\t\t\t\t\tdata-slot=\"silhouette\"\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\tfill=\"currentColor\"\n\t\t\t\t\t\tclassName=\"size-[60%]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<circle cx=\"12\" cy=\"8.5\" r=\"4\" />\n\t\t\t\t\t\t<path d=\"M4 21c0-4.2 3.6-7 8-7s8 2.8 8 7z\" />\n\t\t\t\t\t</svg>\n\t\t\t\t)\n\t\t\t) : null}\n\t\t</span>\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/avatar/Avatar.tsx"},{"path":"react/src/components/avatar/avatar-core.ts","content":"\"use client\";\n\n/**\n * Pure rules behind Avatar, shared byte for byte by the Svelte, React and Vue\n * packages so the same name gives the same initials and the same colour in all\n * three, on the server and in the browser.\n *\n * Nothing here touches the DOM, a timer or a random source.\n */\n\nexport type AvatarStatus = \"loading\" | \"loaded\" | \"fallback\";\n\n/**\n * Soft tinted pairs for the initials fallback. Each entry is a complete static\n * class string on purpose: Tailwind finds a class by scanning the shipped\n * source, so a name built from a variable (`bg-${hue}-500/15`) would never be\n * generated.\n */\nexport const AVATAR_TONES: readonly string[] = [\n\t\"bg-rose-500/15 text-rose-800 dark:text-rose-300\",\n\t\"bg-orange-500/15 text-orange-800 dark:text-orange-300\",\n\t\"bg-amber-500/15 text-amber-800 dark:text-amber-300\",\n\t\"bg-emerald-500/15 text-emerald-800 dark:text-emerald-300\",\n\t\"bg-teal-500/15 text-teal-800 dark:text-teal-300\",\n\t\"bg-sky-500/15 text-sky-800 dark:text-sky-300\",\n\t\"bg-violet-500/15 text-violet-800 dark:text-violet-300\",\n\t\"bg-fuchsia-500/15 text-fuchsia-800 dark:text-fuchsia-300\",\n];\n\n/** Used when there is nothing to take initials from. */\nexport const AVATAR_NEUTRAL_TONE = \"bg-muted text-muted-foreground\";\n\n/** The first letter or digit of a word, as a whole code point. */\nconst WORD_START = /[\\p{L}\\p{N}]/u;\n\n/**\n * One or two letters for a name: the first letter of the first word and of the\n * last word, upper-cased, or a single letter for a one-word name. Leading\n * punctuation is skipped, and a name with no letter or digit gives \"\".\n */\nexport function initialsOf(name: string | null | undefined): string {\n\tif (!name) return \"\";\n\tconst firsts: string[] = [];\n\tfor (const word of name.normalize(\"NFC\").trim().split(/\\s+/)) {\n\t\tconst first = WORD_START.exec(word)?.[0];\n\t\tif (first) firsts.push(first);\n\t}\n\tconst first = firsts[0];\n\tconst last = firsts[firsts.length - 1];\n\tif (first === undefined || last === undefined) return \"\";\n\treturn (firsts.length === 1 ? first : first + last).toUpperCase();\n}\n\n/** Case, Unicode composition and runs of spaces never change a colour. */\nfunction keyOf(name: string): string {\n\treturn name.normalize(\"NFC\").trim().replace(/\\s+/g, \" \").toLowerCase();\n}\n\n/** A stable index in `[0, count)` for a name (a small multiplicative hash). */\nexport function toneIndexOf(name: string, count: number = AVATAR_TONES.length): number {\n\tlet hash = 5381;\n\tfor (const char of keyOf(name)) {\n\t\thash = (Math.imul(hash, 33) ^ (char.codePointAt(0) ?? 0)) >>> 0;\n\t}\n\treturn hash % count;\n}\n\n/** The class pair for a name's fallback: a palette entry, or the neutral one. */\nexport function avatarToneClass(name: string | null | undefined): string {\n\tif (!name || initialsOf(name) === \"\") return AVATAR_NEUTRAL_TONE;\n\treturn AVATAR_TONES[toneIndexOf(name)] ?? AVATAR_NEUTRAL_TONE;\n}\n\n/**\n * Where an image is, given the last `src` that loaded and the last that\n * failed. Keeping the two sources instead of a flag means a new `src` is\n * \"loading\" again with no reset step, and a server render agrees with the\n * first client render.\n */\nexport function resolveAvatarStatus(\n\tsrc: string | null | undefined,\n\tloadedSrc: string | null,\n\tfailedSrc: string | null\n): AvatarStatus {\n\tif (!src) return \"fallback\";\n\tif (failedSrc === src) return \"fallback\";\n\tif (loadedSrc === src) return \"loaded\";\n\treturn \"loading\";\n}\n\n/**\n * The accessible name, or undefined when the avatar is decorative: `alt` wins\n * (so `alt=\"\"` is the explicit opt-out), then `name`; blank text counts as no\n * text.\n */\nexport function accessibleName(\n\talt: string | null | undefined,\n\tname: string | null | undefined\n): string | undefined {\n\tconst label = (alt ?? name)?.trim();\n\treturn label ? label : undefined;\n}\n","type":"registry:ui","target":"@ui/fancy/components/avatar/avatar-core.ts"},{"path":"react/src/components/avatar/index.ts","content":"\"use client\";\n\nexport { Avatar } from \"./Avatar\";\nexport type { AvatarProps, AvatarSize, AvatarShape } from \"./Avatar\";\n","type":"registry:ui","target":"@ui/fancy/components/avatar/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/avatar?framework=react"}}