{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"avatar","type":"registry:ui","title":"Avatar","description":"Avatar in six sizes with an image to initials to icon fallback chain, presence, verified and logo corners, plus a stack that fans out on hover; photos blur up into focus.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/avatar.tsx","type":"registry:ui","target":"components/ui/avatar.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, Plus, User } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type AvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\";\nexport type AvatarStatus = \"online\" | \"away\" | \"busy\" | \"offline\";\n\nconst box: Record<AvatarSize, string> = {\n  xs: \"size-6 text-[10px]\",\n  sm: \"size-8 text-xs\",\n  md: \"size-10 text-sm\",\n  lg: \"size-12 text-base\",\n  xl: \"size-14 text-lg\",\n  \"2xl\": \"size-16 text-xl\",\n};\nconst iconSize: Record<AvatarSize, string> = { xs: \"size-3.5\", sm: \"size-4\", md: \"size-5\", lg: \"size-6\", xl: \"size-7\", \"2xl\": \"size-8\" };\nconst squareRadius: Record<AvatarSize, string> = { xs: \"rounded-[5px]\", sm: \"rounded-xs\", md: \"rounded-sm\", lg: \"rounded-sm\", xl: \"rounded-md\", \"2xl\": \"rounded-md\" };\nconst dot: Record<AvatarSize, string> = { xs: \"size-1.5\", sm: \"size-2\", md: \"size-2.5\", lg: \"size-3\", xl: \"size-3.5\", \"2xl\": \"size-4\" };\nconst corner: Record<AvatarSize, string> = { xs: \"size-2.5\", sm: \"size-3\", md: \"size-3.5\", lg: \"size-4\", xl: \"size-[18px]\", \"2xl\": \"size-5\" };\nconst ringWidth: Record<AvatarSize, string> = { xs: \"ring-1\", sm: \"ring-[1.5px]\", md: \"ring-2\", lg: \"ring-2\", xl: \"ring-[2.5px]\", \"2xl\": \"ring-[3px]\" };\n\n/** The page colour used to \"cut\" rings around corner dots and stacked avatars. Override with [--avatar-cut:var(--color-surface)]. */\nconst CUT = \"ring-[var(--avatar-cut,var(--color-bg))]\";\n\nconst statusTone: Record<AvatarStatus, { dot: string; word: string }> = {\n  online: { dot: \"bg-success\", word: \"online\" },\n  away: { dot: \"bg-warning\", word: \"away\" },\n  busy: { dot: \"bg-danger\", word: \"busy\" },\n  offline: { dot: \"bg-ink-subtle\", word: \"offline\" },\n};\n\nconst tints = [\n  \"from-volt-soft to-success-soft text-volt-ink\",\n  \"from-ember-soft to-warning-soft text-ember-ink\",\n  \"from-info-soft to-volt-soft text-info\",\n  \"from-success-soft to-info-soft text-success\",\n  \"from-warning-soft to-ember-soft text-warning\",\n  \"from-surface-hover to-surface-sunken text-ink-muted\",\n] as const;\n\n/** Stable hash of a string (djb2) — the same name always lands on the same tint, on server and client. */\nfunction hash(input: string) {\n  let h = 5381;\n  for (let i = 0; i < input.length; i++) h = ((h << 5) + h + input.charCodeAt(i)) | 0;\n  return Math.abs(h);\n}\n\n/** First letter of the first and last word, uppercased: \"Mei Tanaka\" -> \"MT\". */\nexport function getInitials(name: string) {\n  const words = name.trim().split(/\\s+/).filter(Boolean);\n  if (!words.length) return \"\";\n  const first = words[0][0] ?? \"\";\n  const last = words.length > 1 ? (words[words.length - 1][0] ?? \"\") : \"\";\n  return (first + last).toUpperCase();\n}\n\nexport interface AvatarProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  size?: AvatarSize;\n  /** Image URL. If it fails to load the avatar falls back to initials, then the placeholder icon. */\n  src?: string | null;\n  /** Person or company name. Drives the initials, the tint and the accessible name. */\n  name?: string;\n  /** Alternative text for the image; defaults to `name`. */\n  alt?: string;\n  /** Override the initials derived from `name`. */\n  initials?: string;\n  /** Placeholder shown when there is no image and no name. */\n  icon?: React.ReactNode;\n  shape?: \"circle\" | \"square\";\n  /** Presence dot in the bottom-right corner, announced as a word. */\n  status?: AvatarStatus;\n  /** Volt check badge. Moves to the top-right corner when status is set. */\n  verified?: boolean;\n  /** Small round node in the bottom-right corner, e.g. a company logo. Hidden when status is set. */\n  badge?: React.ReactNode;\n  /** Thin outer ring so the avatar reads on busy backgrounds. */\n  bordered?: boolean;\n  /** Hide from assistive tech when a visible name sits next to it. */\n  decorative?: boolean;\n}\n\ntype LoadState = \"idle\" | \"loading\" | \"loaded\" | \"error\";\n\n/** Round or square avatar with an image -> initials -> icon fallback chain, a blur-up reveal and corner indicators. */\nexport const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(function Avatar(\n  { size = \"md\", src, name, alt, initials, icon, shape = \"circle\", status, verified, badge, bordered, decorative, className, ...props },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const imgRef = React.useRef<HTMLImageElement>(null);\n  const [load, setLoad] = React.useState<LoadState>(src ? \"loading\" : \"idle\");\n  const [prevSrc, setPrevSrc] = React.useState(src);\n  if (prevSrc !== src) {\n    setPrevSrc(src);\n    setLoad(src ? \"loading\" : \"idle\");\n  }\n\n  // The image can finish (or fail) before hydration attaches onLoad/onError; read its state once mounted.\n  React.useEffect(() => {\n    const img = imgRef.current;\n    if (!img || !img.complete) return;\n    setLoad(img.naturalWidth > 0 ? \"loaded\" : \"error\");\n  }, [src]);\n\n  const text = initials ?? (name ? getInitials(name) : \"\");\n  const tint = tints[hash(name ?? text ?? \"\") % tints.length];\n  const radius = shape === \"circle\" ? \"rounded-full\" : squareRadius[size];\n  const showImage = !!src && load !== \"error\";\n\n  const words = [alt ?? name ?? \"User\", status ? statusTone[status].word : null, verified ? \"verified\" : null].filter(Boolean).join(\", \");\n  const bottomBadge = status ? null : badge;\n  const verifiedCorner = verified ? (status || badge ? \"top\" : \"bottom\") : null;\n\n  return (\n    <span\n      ref={ref}\n      role={decorative ? undefined : \"img\"}\n      aria-label={decorative ? undefined : words}\n      aria-hidden={decorative || undefined}\n      data-avatar=\"\"\n      className={cn(\"relative inline-flex shrink-0 select-none\", box[size], bordered && cn(\"p-[2px] ring-1 ring-border-strong\", radius), className)}\n      {...props}\n    >\n      <span className={cn(\"relative flex size-full items-center justify-center overflow-hidden bg-surface-raised\", radius)}>\n        {/* Fallback layer — visible until the image has loaded, and after it fails. */}\n        <span\n          aria-hidden\n          className={cn(\n            \"absolute inset-0 flex items-center justify-center bg-linear-to-br font-semibold tracking-[-0.02em]\",\n            text ? tint : \"from-surface-hover to-surface-sunken text-ink-subtle\",\n          )}\n        >\n          {text || icon || <User className={iconSize[size]} strokeWidth={1.75} />}\n        </span>\n        {showImage && (\n          <motion.img\n            ref={imgRef}\n            src={src ?? undefined}\n            alt=\"\"\n            draggable={false}\n            onLoad={() => setLoad(\"loaded\")}\n            onError={() => setLoad(\"error\")}\n            initial={false}\n            animate={\n              load === \"loaded\"\n                ? { opacity: 1, scale: 1, filter: \"blur(0px)\" }\n                : { opacity: 0, scale: reduce ? 1 : 1.08, filter: reduce ? \"blur(0px)\" : \"blur(6px)\" }\n            }\n            transition={{ type: \"spring\", stiffness: 260, damping: 30, opacity: { duration: 0.25 } }}\n            className=\"relative size-full object-cover\"\n          />\n        )}\n        {/* Hairline + soft top highlight so photos and tints both get an edge. */}\n        <span aria-hidden className={cn(\"pointer-events-none absolute inset-0 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-ink)_10%,transparent)]\", radius)} />\n      </span>\n\n      {status && <StatusDot status={status} size={size} reduce={!!reduce} />}\n\n      {bottomBadge && (\n        <span\n          aria-hidden\n          className={cn(\n            \"absolute -bottom-0.5 -right-0.5 flex items-center justify-center overflow-hidden rounded-full bg-surface-raised\",\n            corner[size],\n            ringWidth[size],\n            CUT,\n          )}\n        >\n          {bottomBadge}\n        </span>\n      )}\n\n      {verifiedCorner && (\n        <span\n          aria-hidden\n          className={cn(\n            \"absolute -right-0.5 flex items-center justify-center rounded-full bg-volt text-on-volt\",\n            verifiedCorner === \"top\" ? \"-top-0.5\" : \"-bottom-0.5\",\n            corner[size],\n            ringWidth[size],\n            CUT,\n          )}\n        >\n          <Check className=\"size-[70%]\" strokeWidth={3.5} />\n        </span>\n      )}\n    </span>\n  );\n});\n\nfunction StatusDot({ status, size, reduce }: { status: AvatarStatus; size: AvatarSize; reduce: boolean }) {\n  return (\n    <span aria-hidden className={cn(\"absolute bottom-0 right-0 flex items-center justify-center\", dot[size])}>\n      {status === \"online\" && !reduce && (\n        <motion.span\n          className=\"absolute inset-0 rounded-full bg-success\"\n          animate={{ scale: [1, 2.2], opacity: [0.45, 0] }}\n          transition={{ duration: 2.2, repeat: Infinity, ease: \"easeOut\" }}\n        />\n      )}\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={status}\n          initial={{ scale: reduce ? 1 : 0.3, opacity: 0 }}\n          animate={{ scale: 1, opacity: 1 }}\n          exit={{ scale: reduce ? 1 : 0.3, opacity: 0 }}\n          transition={{ type: \"spring\", stiffness: 520, damping: 22 }}\n          className={cn(\"relative size-full rounded-full\", ringWidth[size], CUT, statusTone[status].dot)}\n        />\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst overlap: Record<AvatarSize, string> = { xs: \"-ml-1.5\", sm: \"-ml-2\", md: \"-ml-2.5\", lg: \"-ml-3\", xl: \"-ml-3.5\", \"2xl\": \"-ml-4\" };\nconst spread: Record<AvatarSize, number> = { xs: 3, sm: 4, md: 5, lg: 6, xl: 7, \"2xl\": 8 };\n\nexport interface AvatarGroupProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Avatar elements. Their size is set by the group. */\n  children: React.ReactNode;\n  size?: AvatarSize;\n  /** Show at most this many avatars; the rest collapse into a +N chip. */\n  max?: number;\n  /** Accessible name for the group. */\n  label?: string;\n  /** Renders a dashed add button at the end of the stack. */\n  onAdd?: () => void;\n  addLabel?: string;\n}\n\n/** Overlapping avatar stack that fans out on hover; extra members collapse into a +N chip that names them. */\nexport function AvatarGroup({ children, size = \"sm\", max = 4, label = \"Members\", onAdd, addLabel = \"Add member\", className, ...props }: AvatarGroupProps) {\n  const reduce = useReducedMotion();\n  const [hovered, setHovered] = React.useState(false);\n  const all = React.Children.toArray(children).filter(React.isValidElement) as React.ReactElement<AvatarProps>[];\n  const visible = all.slice(0, Math.max(0, max));\n  const hidden = all.slice(visible.length);\n  const hiddenNames = hidden.map((c) => c.props.name ?? c.props.alt).filter(Boolean) as string[];\n  const fan = (i: number) => (hovered && !reduce ? i * spread[size] : 0);\n  const spring = { type: \"spring\", stiffness: 420, damping: 28 } as const;\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      className={cn(\"flex items-center\", className)}\n      onPointerEnter={() => setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n      {...props}\n    >\n      {visible.map((child, i) => (\n        <motion.span\n          key={child.key ?? i}\n          className={cn(\"relative rounded-full\", i > 0 && overlap[size])}\n          style={{ zIndex: visible.length - i }}\n          animate={{ x: fan(i) }}\n          whileHover={reduce ? undefined : { y: -4, zIndex: 50 }}\n          transition={spring}\n        >\n          {React.cloneElement(child, {\n            size,\n            className: cn(\"rounded-full\", ringWidth[size], CUT, child.props.className),\n          })}\n        </motion.span>\n      ))}\n      {hidden.length > 0 && (\n        <motion.span\n          className={cn(\n            \"relative z-0 inline-flex shrink-0 items-center justify-center rounded-full bg-surface-raised font-mono font-medium text-ink-muted\",\n            box[size],\n            \"text-[11px]\",\n            size === \"xs\" && \"text-[9px]\",\n            ringWidth[size],\n            CUT,\n            overlap[size],\n          )}\n          animate={{ x: fan(visible.length) }}\n          transition={spring}\n          role=\"img\"\n          aria-label={`${hidden.length} more${hiddenNames.length ? `: ${hiddenNames.join(\", \")}` : \"\"}`}\n          title={hiddenNames.join(\", \") || undefined}\n        >\n          +{hidden.length}\n        </motion.span>\n      )}\n      {onAdd && (\n        <motion.button\n          type=\"button\"\n          onClick={onAdd}\n          aria-label={addLabel}\n          title={addLabel}\n          animate={{ x: fan(visible.length + (hidden.length ? 1 : 0)) }}\n          whileTap={{ scale: 0.92 }}\n          transition={spring}\n          className={cn(\n            \"ml-1.5 inline-flex shrink-0 items-center justify-center rounded-full border border-dashed border-border-strong text-ink-subtle\",\n            \"transition-colors duration-150 hover:border-volt-ink hover:text-volt-ink outline-none focus-visible:shadow-[var(--focus-ring)]\",\n            box[size],\n          )}\n        >\n          <Plus className={iconSize[size]} strokeWidth={2} />\n        </motion.button>\n      )}\n    </div>\n  );\n}\n\nconst labelSize = {\n  sm: { avatar: \"sm\" as AvatarSize, name: \"text-[13px]\", sub: \"text-xs\", gap: \"gap-2\" },\n  md: { avatar: \"md\" as AvatarSize, name: \"text-sm\", sub: \"text-[13px]\", gap: \"gap-3\" },\n  lg: { avatar: \"lg\" as AvatarSize, name: \"text-base\", sub: \"text-sm\", gap: \"gap-3.5\" },\n};\n\nexport interface AvatarLabelProps extends Omit<AvatarProps, \"size\" | \"decorative\"> {\n  name: string;\n  /** Second line: email, role or handle. */\n  supporting?: React.ReactNode;\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n\n/** Avatar beside a name and a supporting line (email or role); presence is announced next to the name. */\nexport function AvatarLabel({ name, supporting, size = \"md\", className, status, verified, ...avatarProps }: AvatarLabelProps) {\n  const s = labelSize[size];\n  return (\n    <span className={cn(\"inline-flex min-w-0 items-center\", s.gap, className)}>\n      <Avatar {...avatarProps} name={name} status={status} verified={verified} size={s.avatar} decorative />\n      <span className=\"flex min-w-0 flex-col\">\n        <span className={cn(\"truncate font-semibold leading-tight text-ink\", s.name)}>\n          {name}\n          {(status || verified) && (\n            <span className=\"sr-only\">\n              {status ? `, ${statusTone[status].word}` : \"\"}\n              {verified ? \", verified\" : \"\"}\n            </span>\n          )}\n        </span>\n        {supporting && <span className={cn(\"truncate leading-snug text-ink-muted\", s.sub)}>{supporting}</span>}\n      </span>\n    </span>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","avatar","user","profile","presence","status","group","initials"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/avatar"}}