# Avatar

> Profile image with initials fallback, status light and stacked groups.

Category: Data Display. Docs: https://get-neonkit.vercel.app/components/avatar

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/avatar.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/avatar`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Avatar } from "@/components/ui/avatar"

<Avatar src="/maya.jpg" alt="Maya Okafor" status="online" />
```

## Examples

### Team with status

Initials show until the image loads, and stay if it fails or there is no src.

```tsx
import { Avatar } from "@/components/ui/avatar";

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=facearea&facepad=2&w=128&h=128&q=80`;

const team = [
  { name: "Maya Okafor", role: "Design engineer", src: photo("1494790108377-be9c29b29330"), status: "online", note: "Online" },
  { name: "Daniel Reyes", role: "Platform lead", src: photo("1500648767791-00dcc994a43e"), status: "busy", note: "In a meeting" },
  { name: "Lena Fischer", role: "Product designer", src: photo("1438761681033-6461ffad8d80"), status: "away", note: "Away 20m" },
  { name: "Arjun Mehta", role: "Frontend engineer", src: undefined, status: "offline", note: "Seen 3h ago" },
] as const;

export default function AvatarDemo() {
  return (
    <ul className="grid w-full max-w-sm gap-1">
      {team.map((member) => (
        <li key={member.name} className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-white/[0.03]">
          <Avatar src={member.src} alt={member.name} status={member.status} />
          <div className="min-w-0 flex-1">
            <p className="truncate text-sm font-medium text-white/90">{member.name}</p>
            <p className="truncate text-xs text-white/45">{member.role}</p>
          </div>
          <span className="shrink-0 text-xs text-white/40">{member.note}</span>
        </li>
      ))}
    </ul>
  );
}
```

### Group

AvatarGroup overlaps its children and folds the rest into a +N chip.

```tsx
import { Avatar, AvatarGroup } from "@/components/ui/avatar";

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=facearea&facepad=2&w=128&h=128&q=80`;

const editors = [
  { name: "Maya Okafor", src: photo("1494790108377-be9c29b29330") },
  { name: "Daniel Reyes", src: photo("1500648767791-00dcc994a43e") },
  { name: "Lena Fischer", src: photo("1438761681033-6461ffad8d80") },
  { name: "Tom Becker", src: photo("1472099645785-5658abf4ff4e") },
  { name: "Sofia Marin", src: photo("1517841905240-472988babdf9") },
  { name: "Noah Kim", src: photo("1519244703995-f4e0f30006d5") },
  { name: "Arjun Mehta", src: undefined },
];

export default function AvatarGroupExample() {
  return (
    <div className="flex flex-col items-center gap-8">
      <div className="flex items-center gap-3">
        <AvatarGroup max={4}>
          {editors.map((person) => (
            <Avatar key={person.name} src={person.src} alt={person.name} />
          ))}
        </AvatarGroup>
        <p className="text-sm text-white/55">
          <span className="text-white/90">Maya, Daniel</span> and 5 others are editing
        </p>
      </div>
      <AvatarGroup size="sm" max={5}>
        {editors.map((person) => (
          <Avatar key={person.name} src={person.src} alt={person.name} />
        ))}
      </AvatarGroup>
    </div>
  );
}
```

### Sizes and shapes

Square suits workspaces and organisations.

```tsx
import { Avatar } from "@/components/ui/avatar";

const src =
  "https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=facearea&facepad=2&w=128&h=128&q=80";

const sizes = ["sm", "md", "lg", "xl"] as const;
const workspaces = ["Lumen Labs", "Northwind", "Ownpath Studio", "Halcyon"];

export default function AvatarSizes() {
  return (
    <div className="flex flex-col items-center gap-8">
      <div className="flex items-end gap-5">
        {sizes.map((size) => (
          <Avatar key={size} size={size} src={src} alt="Priya Shah" status="online" />
        ))}
      </div>
      <div className="flex items-end gap-5">
        {sizes.map((size, index) => (
          <Avatar key={size} size={size} shape="square" alt={workspaces[index]} />
        ))}
      </div>
    </div>
  );
}
```

### Ring and color

ring marks the active person. color tints the initials and the ring.

```tsx
import { Avatar } from "@/components/ui/avatar";

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=facearea&facepad=2&w=128&h=128&q=80`;

const participants = [
  { name: "Lena Fischer", src: photo("1438761681033-6461ffad8d80"), speaking: true },
  { name: "Tom Becker", src: photo("1472099645785-5658abf4ff4e"), speaking: false },
  { name: "Sofia Marin", src: photo("1517841905240-472988babdf9"), speaking: false },
  { name: "Noah Kim", src: photo("1519244703995-f4e0f30006d5"), speaking: false },
];

const guests = [
  { name: "Ines Duarte", color: "violet" },
  { name: "Kai Nakamura", color: "magenta" },
  { name: "Ruth Adeyemi", color: "amber" },
  { name: "Elias Berg", color: "lime" },
] as const;

export default function AvatarRing() {
  return (
    <div className="flex flex-col items-center gap-8">
      <div className="flex items-start gap-6">
        {participants.map((person) => (
          <div key={person.name} className="flex w-16 flex-col items-center gap-2.5">
            <Avatar size="lg" src={person.src} alt={person.name} ring={person.speaking} />
            <span className={person.speaking ? "text-xs text-white/90" : "text-xs text-white/45"}>
              {person.name.split(" ")[0]}
            </span>
          </div>
        ))}
      </div>
      <div className="flex items-center gap-3">
        {guests.map((guest) => (
          <Avatar key={guest.name} alt={guest.name} color={guest.color} />
        ))}
        <span className="ml-1 text-xs text-white/40">Guests get their own color</span>
      </div>
    </div>
  );
}
```

## API reference

### Avatar

Renders a span and accepts every native span attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `alt` | `string` | — | The person's name. Read by screen readers and used for the initials. Pass "" when decorative. |
| `src` | `string` | — | Image URL. Fades in once loaded. |
| `fallback` | `ReactNode` | — | Replaces the initials, for example with an icon. |
| `size` | `"sm" \| "md" \| "lg" \| "xl"` | `"md"` | 28, 36, 48 or 64px. |
| `shape` | `"circle" \| "square"` | `"circle"` | Outline shape. |
| `status` | `"online" \| "away" \| "busy" \| "offline"` | — | Status light in the bottom-right corner. |
| `ring` | `boolean` | `false` | Neon ring with a glow, set off by a thin dark gap. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### AvatarGroup

Renders a div. Size and shape apply to every Avatar inside.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `max` | `number` | — | Avatars past this count collapse into a +N chip. |
| `size` | `"sm" \| "md" \| "lg" \| "xl"` | `"md"` | Size of every avatar and the chip. |
| `shape` | `"circle" \| "square"` | `"circle"` | Shape of every avatar and the chip. |
| `color` | `NeonColor` | `inherited` | Color for every avatar in the group. |

## Accessibility

- Each avatar is a single image to assistive tech, named by alt, with the status appended, e.g. "Maya Okafor (online)".
- The +N chip is announced as "N more".
- The status light is cut out with #05060a; change it with className if your surface is a different color.

## Source

`components/ui/avatar.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const sizes = {
  sm: { root: "size-7 text-[11px]", square: "rounded-md", dot: "size-2 bottom-0 right-0", overlap: "[&>*+*]:-ml-1.5" },
  md: { root: "size-9 text-[13px]", square: "rounded-lg", dot: "size-2.5 bottom-0 right-0", overlap: "[&>*+*]:-ml-2" },
  lg: { root: "size-12 text-base", square: "rounded-xl", dot: "size-3 bottom-px right-px", overlap: "[&>*+*]:-ml-3" },
  xl: { root: "size-16 text-xl", square: "rounded-2xl", dot: "size-3.5 bottom-0.5 right-0.5", overlap: "[&>*+*]:-ml-4" },
} as const;

// The 2px #05060a ring cuts the light out of the avatar edge.
const statuses = {
  online: "bg-[rgb(52_211_153)] shadow-[0_0_0_2px_#05060a,0_0_8px_2px_rgb(52_211_153/0.45)]",
  away: "bg-[rgb(251_191_36)] shadow-[0_0_0_2px_#05060a,0_0_8px_2px_rgb(251_191_36/0.4)]",
  busy: "bg-[rgb(255_77_94)] shadow-[0_0_0_2px_#05060a,0_0_8px_2px_rgb(255_77_94/0.45)]",
  offline: "bg-[#05060a] shadow-[0_0_0_2px_#05060a,inset_0_0_0_2px_rgb(255_255_255/0.3)]",
} as const;

type AvatarSize = keyof typeof sizes;
type AvatarShape = "circle" | "square";
type AvatarStatus = keyof typeof statuses;

const AvatarGroupContext = React.createContext<{ size: AvatarSize; shape: AvatarShape } | null>(null);

function getInitials(name: string) {
  const words = name.trim().split(/\s+/).filter(Boolean);
  if (words.length === 0) return "";
  const first = Array.from(words[0])[0];
  const last = words.length > 1 ? Array.from(words[words.length - 1])[0] : "";
  return (first + last).toUpperCase();
}

function useImageStatus(src?: string) {
  const [result, setResult] = React.useState<{ src: string; status: "loaded" | "error" } | null>(null);

  React.useEffect(() => {
    if (!src) return;
    let active = true;
    const image = new window.Image();
    image.onload = () => {
      if (active) setResult({ src, status: "loaded" });
    };
    image.onerror = () => {
      if (active) setResult({ src, status: "error" });
    };
    image.src = src;
    return () => {
      active = false;
    };
  }, [src]);

  if (!src) return "error";
  return result?.src === src ? result.status : "loading";
}

export interface AvatarProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color"> {
  /** Palette name or any hex color. Tints the fallback and the ring. */
  color?: NeonColor;
  src?: string;
  /** The person's name. Read by screen readers and used for the initials. Pass "" when decorative. */
  alt: string;
  /** Shown instead of the initials while the image loads or when it fails. */
  fallback?: React.ReactNode;
  size?: AvatarSize;
  shape?: AvatarShape;
  /** Neon ring with a glow, separated from the avatar by a thin gap. */
  ring?: boolean;
  status?: AvatarStatus;
}

const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
  (
    { className, style, color, src, alt, fallback, size: sizeProp, shape: shapeProp, ring = false, status, ...props },
    ref,
  ) => {
    const group = React.useContext(AvatarGroupContext);
    const size = sizeProp ?? group?.size ?? "md";
    const shape = shapeProp ?? group?.shape ?? "circle";
    const s = sizes[size];
    const radius = shape === "circle" ? "rounded-full" : s.square;
    const imageStatus = useImageStatus(src);
    const loaded = imageStatus === "loaded";

    return (
      <span
        ref={ref}
        role={alt ? "img" : undefined}
        aria-label={alt ? (status ? `${alt} (${status})` : alt) : undefined}
        aria-hidden={alt ? undefined : true}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "relative inline-flex shrink-0 select-none align-middle font-medium leading-none",
          s.root,
          radius,
          ring
            ? "shadow-[0_0_0_2px_#05060a,0_0_0_3px_rgb(var(--neon)),0_0_14px_3px_rgb(var(--neon)/0.4)]"
            : group && "shadow-[0_0_0_2px_#05060a]",
          className,
        )}
        {...props}
      >
        <span
          aria-hidden
          className={cn(
            "absolute inset-0 flex items-center justify-center overflow-hidden",
            "bg-[#0b0c12] bg-[linear-gradient(rgb(var(--neon)/0.12),rgb(var(--neon)/0.12))] text-[rgb(var(--neon))] [&_svg]:size-1/2",
            radius,
          )}
        >
          <span
            className={cn(
              "flex items-center justify-center transition-opacity duration-200 ease-out motion-reduce:transition-none",
              loaded && "opacity-0",
            )}
          >
            {fallback ?? getInitials(alt)}
          </span>
          {src && imageStatus !== "error" && (
            <span
              className={cn(
                "absolute inset-0 bg-cover bg-center transition-opacity duration-300 ease-out motion-reduce:transition-none",
                loaded ? "opacity-100" : "opacity-0",
              )}
              style={{ backgroundImage: `url(${JSON.stringify(src)})` }}
            />
          )}
          <span
            className={cn(
              "pointer-events-none absolute inset-0 rounded-[inherit] transition-shadow duration-300",
              loaded
                ? "shadow-[inset_0_0_0_1px_rgb(255_255_255/0.1)]"
                : "shadow-[inset_0_0_0_1px_rgb(var(--neon)/0.2)]",
            )}
          />
        </span>
        {status && (
          <span
            aria-hidden
            className={cn(
              "absolute rounded-full",
              s.dot,
              shape === "square" && "-bottom-0.5 -right-0.5",
              statuses[status],
            )}
          />
        )}
      </span>
    );
  },
);
Avatar.displayName = "Avatar";

export interface AvatarGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color, applied to every avatar in the group. */
  color?: NeonColor;
  /** Avatars past this count collapse into a "+N" chip. */
  max?: number;
  size?: AvatarSize;
  shape?: AvatarShape;
}

const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
  ({ className, style, color, max, size = "md", shape = "circle", children, ...props }, ref) => {
    const items = React.Children.toArray(children).filter(React.isValidElement);
    const visible = max !== undefined && items.length > max ? items.slice(0, Math.max(max, 0)) : items;
    const hidden = items.length - visible.length;
    const context = React.useMemo(() => ({ size, shape }), [size, shape]);

    return (
      <AvatarGroupContext.Provider value={context}>
        <div
          ref={ref}
          style={neonVars(color, style)}
          className={cn("flex items-center", sizes[size].overlap, className)}
          {...props}
        >
          {visible}
          {hidden > 0 && (
            <span
              role="img"
              aria-label={`${hidden} more`}
              className={cn(
                "relative inline-flex shrink-0 select-none items-center justify-center bg-[#0f1118] font-medium leading-none text-white/60 tabular-nums",
                "shadow-[0_0_0_2px_#05060a,inset_0_0_0_1px_rgb(255_255_255/0.1)]",
                sizes[size].root,
                shape === "circle" ? "rounded-full" : sizes[size].square,
              )}
            >
              +{hidden}
            </span>
          )}
        </div>
      </AvatarGroupContext.Provider>
    );
  },
);
AvatarGroup.displayName = "AvatarGroup";

export { Avatar, AvatarGroup };
```
