{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar",
  "type": "registry:component",
  "title": "Avatar",
  "description": "A person or a team as a picture, or their initials in a colour of their own, with a badge and a group that opens up.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/panelui/avatar.tsx",
      "type": "registry:component",
      "target": "@components/panelui/avatar.tsx",
      "content": "'use client';\n\n/**\n * Avatar — a person or a team as a picture, with their initials when there\n * is no picture to show.\n *\n * ```tsx\n * <Avatar src=\"/noor.jpg\" name=\"Noor Haddad\" />\n *\n * <Avatar name=\"Tomás Ferreira\" size=\"lg\">\n *   <Avatar.Badge status=\"online\" />\n * </Avatar>\n *\n * <Avatar.Group max={4}>\n *   {people.map((person) => <Avatar key={person.id} src={person.photo} name={person.name} />)}\n * </Avatar.Group>\n * ```\n *\n * ## Initials in a colour of their own\n *\n * Without a picture, or while one loads, the avatar shows the initials of\n * `name` on a tint of one of the theme's chart colours. The colour is picked\n * from the name, so the same person has the same colour on every screen, and\n * a row of people without pictures can still be told apart at a glance.\n *\n * ## Cut out, not ringed\n *\n * A badge, and the avatar overlapping the next one in a group, are not drawn\n * with a ring in the page's background colour. The avatar underneath is cut\n * away with a mask instead, so the gap round them is a real gap: it shows\n * whatever the avatar sits on, a card, a tinted row, a photograph, and needs\n * no colour to match it.\n *\n * ## A group opens up\n *\n * Avatars in a group overlap. Hovered or focused, they move apart, and the cut\n * in each moves with the one beside it. Past `max`, the rest are counted in a\n * last circle that opens a list of who they are.\n */\nimport { Avatar as BaseAvatar } from '@base-ui/react/avatar';\nimport { Popover } from '@base-ui/react/popover';\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  useContext,\n  useMemo,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactElement,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\nexport type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';\nexport type AvatarShape = 'circle' | 'square';\nexport type AvatarColor = 'chart-1' | 'chart-2' | 'chart-3' | 'chart-4' | 'chart-5' | 'muted';\nexport type AvatarStatus = 'online' | 'away' | 'busy' | 'offline';\n\nconst SIZE: Record<AvatarSize, string> = {\n  xs: '1.5rem',\n  sm: '2rem',\n  md: '2.5rem',\n  lg: '3.5rem',\n  xl: '4.5rem',\n};\n\nconst COLORS: AvatarColor[] = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5'];\n\nconst avatarVariants = tv({\n  slots: {\n    root: 'relative inline-flex size-(--avatar-size) shrink-0 align-middle',\n    /*\n     * The face is what gets cut. Its hairline is inside it, so the cut takes\n     * the hairline with it and no stray arc of it is left in the gap.\n     */\n    face: [\n      'relative flex size-full overflow-hidden bg-muted select-none',\n      \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-1 after:ring-inset after:ring-foreground/10 after:content-['']\",\n      '[mask-repeat:no-repeat] [mask-composite:intersect]',\n    ],\n    image: 'size-full object-cover',\n    fallback: [\n      'flex size-full items-center justify-center font-medium leading-none tracking-tight',\n      'text-[calc(var(--avatar-size)*0.38)]',\n      'bg-[color-mix(in_oklab,var(--avatar-color)_22%,var(--card))] text-[color-mix(in_oklab,var(--avatar-color)_70%,var(--foreground))]',\n      '[&_svg]:size-[55%] [&_svg]:opacity-70',\n    ],\n    badge: [\n      'absolute flex size-(--badge-size) items-center justify-center rounded-full',\n      'left-[calc(var(--badge-center)-var(--badge-size)/2)] top-[calc(var(--badge-center)-var(--badge-size)/2)]',\n      'text-[calc(var(--badge-size)*0.6)] [&_svg]:size-[70%]',\n    ],\n    group: [\n      'group/avatars inline-flex items-center [--avatar-overlap:0.22]',\n      // Hovered or focused, the group opens up so each face can be seen whole.\n      'hover:[--avatar-overlap:0.08] has-focus-visible:[--avatar-overlap:0.08] motion-reduce:hover:[--avatar-overlap:0.22]',\n    ],\n    item: [\n      'not-first:ms-[calc(var(--avatar-size)*var(--avatar-overlap)*-1)]',\n      'transition-[margin] duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',\n      // The face's cut follows the margin, in step with it.\n      '*:data-[slot=avatar-face]:transition-[mask-position] *:data-[slot=avatar-face]:duration-300 *:data-[slot=avatar-face]:ease-[cubic-bezier(0.2,0,0,1)]',\n    ],\n    count: [\n      'flex size-full cursor-pointer items-center justify-center bg-muted font-medium tabular-nums text-muted-foreground outline-none',\n      'text-[calc(var(--avatar-size)*0.34)] transition-colors hover:bg-accent hover:text-foreground',\n      'focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground',\n    ],\n    popup: [\n      'z-50 min-w-52 origin-(--transform-origin) rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg outline-none',\n      'max-h-(--available-height) overflow-y-auto',\n      'transition-[opacity,scale] duration-150 ease-out data-starting-style:scale-95 data-starting-style:opacity-0 data-ending-style:scale-95 data-ending-style:opacity-0',\n      'motion-reduce:transition-none',\n    ],\n    row: 'flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm',\n  },\n  variants: {\n    shape: {\n      circle: { face: 'rounded-full', root: '[--badge-center:85.4%]' },\n      // The badge sits further in on a square, whose corner is further out.\n      square: { face: 'rounded-[28%]', root: '[--badge-center:calc(100%-var(--badge-size)*0.3)]' },\n    },\n    status: {\n      online: { badge: 'bg-success' },\n      away: { badge: 'bg-warning' },\n      busy: { badge: 'bg-destructive' },\n      // Hollow: there, but not reachable.\n      offline: { badge: 'border-[max(1.5px,calc(var(--badge-size)*0.2))] border-muted-foreground/70' },\n    },\n    color: {\n      'chart-1': { root: '[--avatar-color:var(--chart-1)]', badge: 'bg-chart-1 text-background' },\n      'chart-2': { root: '[--avatar-color:var(--chart-2)]', badge: 'bg-chart-2 text-background' },\n      'chart-3': { root: '[--avatar-color:var(--chart-3)]', badge: 'bg-chart-3 text-background' },\n      'chart-4': { root: '[--avatar-color:var(--chart-4)]', badge: 'bg-chart-4 text-background' },\n      'chart-5': { root: '[--avatar-color:var(--chart-5)]', badge: 'bg-chart-5 text-background' },\n      muted: { root: '[--avatar-color:var(--muted-foreground)]', badge: 'bg-foreground text-background' },\n    },\n  },\n  defaultVariants: { shape: 'circle' },\n});\n\n/** The same name always lands on the same colour. */\nfunction colorOf(name: string | undefined): AvatarColor {\n  if (!name) return 'muted';\n  let hash = 0;\n  for (const char of name) hash = (hash * 31 + char.codePointAt(0)!) | 0;\n  return COLORS[Math.abs(hash) % COLORS.length]!;\n}\n\n/** First letter of the first and last words: \"Noor al-Haddad\" is \"NA\". */\nfunction initialsOf(name: string): string {\n  const words = name.trim().split(/\\s+/).filter(Boolean);\n  if (words.length === 0) 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\nconst STATUS_LABEL: Record<AvatarStatus, string> = {\n  online: 'online',\n  away: 'away',\n  busy: 'busy',\n  offline: 'offline',\n};\n\n/* -------------------------------------------------------------------------- */\n/* Masks                                                                      */\n/* -------------------------------------------------------------------------- */\n\n/*\n * Every face has two mask layers, intersected: one for the badge, one for the\n * avatar overlapping it in a group. A layer with nothing to cut is solid.\n *\n * Each cut is a gradient drawn in a box bigger than the face and placed with\n * `mask-position`, rather than a gradient placed by its own coordinates. A\n * position animates; a gradient's coordinates do not. That is what lets the\n * cut slide along with the next avatar as the group opens.\n */\nconst SOLID = 'linear-gradient(#000,#000)';\nconst GAP = 'max(1.5px, calc(var(--avatar-size) * 0.05))';\n\n/** A hole round the badge. */\nconst BADGE_CUT = `radial-gradient(circle at var(--badge-center) var(--badge-center), transparent calc(var(--badge-size) / 2 + ${GAP}), #000 calc(var(--badge-size) / 2 + ${GAP} + 0.5px))`;\n\n/**\n * A hole where the next avatar lies over this one. The box is three faces\n * wide with the hole at its centre, then shifted so the centre lands on the\n * next avatar's centre: `size × (1 − overlap)` along.\n */\nconst CIRCLE_CUT = `radial-gradient(circle, transparent calc(var(--avatar-size) / 2 + ${GAP}), #000 calc(var(--avatar-size) / 2 + ${GAP} + 0.5px))`;\nconst CIRCLE_CUT_SIZE = 'calc(var(--avatar-size) * 3) calc(var(--avatar-size) * 3)';\nconst CIRCLE_CUT_AT = 'calc(var(--avatar-size) * var(--avatar-overlap) * -1) calc(var(--avatar-size) * -1)';\n\n/**\n * A square's hole is the next square's own shape, a rounded square a little\n * larger than it, drawn in the same three-face box so it moves the same way.\n * A gradient cannot draw a rounded square, so this one is an SVG: 100 units\n * to a face, with the corner at the face's 28% and the gap at 5%.\n */\nconst SQUARE_CUT = `url(\"data:image/svg+xml,${encodeURIComponent(\n  '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 300 300\" preserveAspectRatio=\"none\"><path fill-rule=\"evenodd\" d=\"M0 0H300V300H0Z M128 95H172A33 33 0 0 1 205 128V172A33 33 0 0 1 172 205H128A33 33 0 0 1 95 172V128A33 33 0 0 1 128 95Z\"/></svg>'\n)}\")`;\n\nfunction maskStyle(shape: AvatarShape, badge: boolean, overlapped: boolean): CSSProperties | undefined {\n  if (!badge && !overlapped) return undefined;\n  const layers = [\n    badge ? [BADGE_CUT, '100% 100%', '0 0'] : [SOLID, '100% 100%', '0 0'],\n    overlapped\n      ? shape === 'circle'\n        ? [CIRCLE_CUT, CIRCLE_CUT_SIZE, CIRCLE_CUT_AT]\n        : [SQUARE_CUT, CIRCLE_CUT_SIZE, CIRCLE_CUT_AT]\n      : [SOLID, '100% 100%', '0 0'],\n  ];\n  return {\n    maskImage: layers.map((layer) => layer[0]).join(', '),\n    maskSize: layers.map((layer) => layer[1]).join(', '),\n    maskPosition: layers.map((layer) => layer[2]).join(', '),\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Avatar                                                                     */\n/* -------------------------------------------------------------------------- */\n\ninterface GroupContextValue {\n  size: AvatarSize;\n  shape: AvatarShape;\n}\n\nconst GroupContext = createContext<GroupContextValue | null>(null);\n/** Set on every avatar in a group but the last, which nothing overlaps. */\nconst OverlappedContext = createContext(false);\n\nexport interface AvatarProps extends Omit<ComponentProps<'span'>, 'color'> {\n  /** The picture. Without it, or until it has loaded, the initials show. */\n  src?: string;\n  /** Who it is. Gives the initials, the colour and what a screen reader says. */\n  name?: string;\n  /** Read out in place of `name`, when that is not the right thing to say. */\n  alt?: string;\n  /** Default `md`. In a group, the group's size. */\n  size?: AvatarSize;\n  /** `circle` for people, `square` for teams, projects and things. Default `circle`. */\n  shape?: AvatarShape;\n  /** The tint behind the initials. Picked from `name` when left out. */\n  color?: AvatarColor;\n  /** In place of the initials, such as an icon for a team or a bot. */\n  fallback?: ReactNode;\n  /** An `Avatar.Badge`. */\n  children?: ReactNode;\n}\n\n/** A picture of someone, or their initials in a colour of their own. */\nfunction AvatarRoot({\n  src,\n  name,\n  alt,\n  size,\n  shape,\n  color,\n  fallback,\n  className,\n  style,\n  children,\n  render,\n  ...props\n}: AvatarProps & RenderProp<'span'>) {\n  const group = useContext(GroupContext);\n  const overlapped = useContext(OverlappedContext);\n  const resolvedSize = size ?? group?.size ?? 'md';\n  const resolvedShape = shape ?? group?.shape ?? 'circle';\n  const slots = avatarVariants({ shape: resolvedShape, color: color ?? colorOf(name) });\n\n  // A badge's status is part of who it is, so it is said with the name.\n  const badge = Children.toArray(children).find(\n    (child): child is ReactElement<AvatarBadgeProps> => isValidElement(child) && child.type === AvatarBadge\n  );\n  const status = badge?.props.label ?? (badge?.props.status ? STATUS_LABEL[badge.props.status] : undefined);\n  const label = [alt ?? name, status].filter(Boolean).join(', ') || undefined;\n\n  return useRender({\n    defaultTagName: 'span',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'avatar',\n        'data-size': resolvedSize,\n        'data-shape': resolvedShape,\n        role: label ? 'img' : undefined,\n        'aria-label': label,\n        className: slots.root({ className }),\n        style: {\n          ['--avatar-size' as string]: SIZE[resolvedSize],\n          // Here and not on the badge, so the face's cut and the badge's place can both read it.\n          ['--badge-size' as string]: 'max(0.5rem, calc(var(--avatar-size) * 0.28))',\n          ...style,\n        },\n        children: (\n          <>\n            <BaseAvatar.Root\n              data-slot=\"avatar-face\"\n              aria-hidden=\"true\"\n              className={slots.face()}\n              style={maskStyle(resolvedShape, badge !== undefined, overlapped)}\n            >\n              {src ? <BaseAvatar.Image src={src} alt=\"\" className={slots.image()} /> : null}\n              <BaseAvatar.Fallback className={slots.fallback()}>\n                {fallback ?? (name ? initialsOf(name) : <PersonGlyph />)}\n              </BaseAvatar.Fallback>\n            </BaseAvatar.Root>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nAvatarRoot.displayName = 'Avatar';\n\nfunction PersonGlyph() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\">\n      <circle cx=\"12\" cy=\"8.5\" r=\"4\" />\n      <path d=\"M4 20.5c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5z\" />\n    </svg>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Badge                                                                      */\n/* -------------------------------------------------------------------------- */\n\nexport interface AvatarBadgeProps extends Omit<ComponentProps<'span'>, 'color'> {\n  /** A presence dot in the colour that says it. Leave out for a badge of your own. */\n  status?: AvatarStatus;\n  /** For a badge with an icon in it: the fill behind the icon. */\n  color?: AvatarColor;\n  /** What a screen reader says after the name. Defaults to the status. */\n  label?: string;\n  /** An icon, such as a check for a verified account. */\n  children?: ReactNode;\n}\n\n/**\n * A dot or a small icon on the avatar's lower trailing edge. The avatar is\n * cut away round it, so it reads on any background.\n */\nfunction AvatarBadge({\n  status,\n  color,\n  label: _label,\n  className,\n  render,\n  ...props\n}: AvatarBadgeProps & RenderProp<'span'>) {\n  const slots = avatarVariants({ status, color: status ? undefined : (color ?? 'muted') });\n  return useRender({\n    defaultTagName: 'span',\n    render,\n    props: mergeProps(\n      {\n        'aria-hidden': 'true',\n        'data-slot': 'avatar-badge',\n        'data-status': status,\n        className: slots.badge({ className }),\n      },\n      props\n    ),\n  });\n}\nAvatarBadge.displayName = 'Avatar.Badge';\n\n/* -------------------------------------------------------------------------- */\n/* Group                                                                      */\n/* -------------------------------------------------------------------------- */\n\nexport interface AvatarGroupProps extends ComponentProps<'div'> {\n  /** How many to show. The rest are counted in a last circle that lists them. */\n  max?: number;\n  /** The size of every avatar in it. Default `md`. */\n  size?: AvatarSize;\n  /** The shape of every avatar in it. Default `circle`. */\n  shape?: AvatarShape;\n  /** What a screen reader calls the group, such as \"Members\". */\n  label?: string;\n}\n\n/** A row of overlapping avatars, that opens up when it is hovered or focused. */\nfunction AvatarGroup({\n  max,\n  size = 'md',\n  shape = 'circle',\n  label,\n  className,\n  style,\n  children,\n  ...props\n}: AvatarGroupProps) {\n  const slots = avatarVariants({ shape });\n  const context = useMemo(() => ({ size, shape }), [size, shape]);\n  const all = Children.toArray(children).filter(isValidElement) as ReactElement<AvatarProps>[];\n  const shown = max !== undefined && all.length > max ? all.slice(0, Math.max(0, max)) : all;\n  const rest = all.slice(shown.length);\n  const items: ReactNode[] = shown.map((child, index) => (\n    <OverlappedContext.Provider key={child.key ?? index} value={index < shown.length - 1 || rest.length > 0}>\n      <span data-slot=\"avatar-group-item\" className={slots.item()}>\n        {child}\n      </span>\n    </OverlappedContext.Provider>\n  ));\n\n  return (\n    <GroupContext.Provider value={context}>\n      <div\n        role=\"group\"\n        aria-label={label}\n        data-slot=\"avatar-group\"\n        className={slots.group({ className })}\n        style={{ ['--avatar-size' as string]: SIZE[size], ...style }}\n        {...props}\n      >\n        {items}\n        {rest.length > 0 ? (\n          <span data-slot=\"avatar-group-item\" className={slots.item()}>\n            <AvatarGroupCount people={rest} shape={shape} />\n          </span>\n        ) : null}\n      </div>\n    </GroupContext.Provider>\n  );\n}\nAvatarGroup.displayName = 'Avatar.Group';\n\n/** \"+3\", which opens a list of the three. */\nfunction AvatarGroupCount({ people, shape }: { people: ReactElement<AvatarProps>[]; shape: AvatarShape }) {\n  const slots = avatarVariants({ shape });\n  const names = people.map((person) => person.props.alt ?? person.props.name).filter(Boolean);\n  return (\n    <span data-slot=\"avatar\" className={slots.root()}>\n      <Popover.Root>\n        <Popover.Trigger\n          data-slot=\"avatar-group-count\"\n          aria-label={`${people.length} more${names.length ? `: ${names.join(', ')}` : ''}`}\n          className={avatarVariants({ shape }).face({ className: slots.count() })}\n        >\n          +{people.length}\n        </Popover.Trigger>\n        <Popover.Portal>\n          <Popover.Positioner side=\"bottom\" align=\"start\" sideOffset={8} className=\"z-50\">\n            <Popover.Popup className={slots.popup()}>\n              <GroupContext.Provider value={null}>\n                {people.map((person, index) => (\n                  <div key={person.key ?? index} className={slots.row()}>\n                    <AvatarRoot {...person.props} size=\"xs\" shape={shape}>\n                      {null}\n                    </AvatarRoot>\n                    <span className=\"min-w-0 truncate\">{person.props.name ?? person.props.alt}</span>\n                  </div>\n                ))}\n              </GroupContext.Provider>\n            </Popover.Popup>\n          </Popover.Positioner>\n        </Popover.Portal>\n      </Popover.Root>\n    </span>\n  );\n}\n\nexport const Avatar = Object.assign(AvatarRoot, {\n  Badge: AvatarBadge,\n  Group: AvatarGroup,\n});\n\nexport { AvatarBadge, AvatarGroup };\n"
    }
  ]
}
