Component
Avatar
Avatars represent a person visually — as a photo, initials, or a fallback icon — with an optional presence or verification badge. Every size, surface, and status colour is wired to the --components-avatar-* tokens.
Playground
Type
Size
Alt text
Status badge
Status
Preview
JSX
<Avatar
type="image"
size="xs"
src="/avatars/aakash.jpg"
alt="Aakash Prajapati"
status="online"
showStatusIcon
/>Props
The full AvatarProps API. Props marked Required have no default.
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'image' | 'initials' | 'icon' | 'image' | Visual representation of the avatar — a photo, initials, or a fallback icon. |
size | 'xs' | 'sm' | 'lg' | 'x-lg' | '2x-lg' | 'xs' | Avatar diameter (24 / 32 / 40 / 48 / 56px) — also drives the status-badge size and initials typography. |
status | 'online' | 'offline' | 'away' | 'busy' | 'verified' | 'online' | Presence or verification state shown in the status badge. |
showStatusIcon | boolean | true | Show the status badge at the bottom-right. Requires status. |
label | string | 'AP' | Initials text — required when type="initials" (e.g. "AP", max 2 chars). |
src | string | — | Image URL — required when type="image". |
alt | string | — | Alt text — required when type="image"; use the person's full name. |
changeIcon | ReactNode | PersonOutlineIcon | Icon-swap override — only applies when type="icon". Defaults to PersonOutlineIcon (M_person_outline); pass any icon from nexus/icons to swap it. |
className | string | — | Layout-only classes (margin, positioning). |
Usage guidelines
When to use
- Show a person's identity — profile pictures in headers, sidebars, and account menus.
- Attribute content in comment threads, chat messages, and activity feeds.
- Represent members in directories, mention dropdowns, and assigned-to indicators.
- Pair with a status badge to surface presence or a verification seal.
When not to use
- Don't use for non-person entities — use a dedicated icon or illustration instead.
- Don't make the Avatar itself a click target; wrap it in a Button or Link.
- Don't use it for decorative imagery — reach for an image or illustration component.
- Don't pass arbitrary custom sizes; always use the defined size values.
nexus/components/avatar.tsx
import type { ReactNode } from "react";
import { PersonOutlineIcon } from "nexus/icons";
import {
StatusIcon,
type AvatarSize,
type AvatarStatus,
} from "./status-icon";
export type { AvatarSize, AvatarStatus } from "./status-icon";
export type AvatarType = "image" | "initials" | "icon";
// Avatar diameter per size (24 / 32 / 40 / 48 / 56px). 56 has no spacing token.
const FRAME_SIZE: Record<AvatarSize, string> = {
xs: "size-24",
sm: "size-32",
lg: "size-40",
"x-lg": "size-48",
"2x-lg": "size-[56px]",
};
// Person-icon diameter per avatar size (passed as numeric SVG props).
const PERSON_ICON_SIZE: Record<AvatarSize, number> = {
xs: 16,
sm: 20,
lg: 24,
"x-lg": 28,
"2x-lg": 32,
};
// Initials typography per avatar size.
const INITIALS_TYPO: Record<AvatarSize, string> = {
xs: "typography-l1-medium-12",
sm: "typography-b2-medium-14",
lg: "typography-b1-medium-16",
"x-lg": "typography-h4-semibold-18",
"2x-lg": "typography-h3-semibold-24",
};
const cx = (...classes: (string | false | undefined)[]) =>
classes.filter(Boolean).join(" ");
const ICON_FILL = "[&_path]:fill-[var(--components-avatar-color-nuetral-icon-primary)]";
export interface AvatarProps {
/** Visual representation of the avatar. */
type?: AvatarType;
/** Avatar diameter — also drives status-badge size and initials typography. */
size?: AvatarSize;
/** Presence / verification state shown in the status badge. */
status?: AvatarStatus;
/** Show the status badge at the bottom-right. Requires `status`. */
showStatusIcon?: boolean;
/** Initials text — required when `type="initials"` (e.g. "AP", max 2 chars). */
label?: string;
/** Image URL — required when `type="image"`. */
src?: string;
/** Alt text — required when `type="image"`; use the person's full name. */
alt?: string;
/** Override the default person icon — only applies when `type="icon"`. */
changeIcon?: ReactNode;
/** Layout-only classes (margin, positioning). */
className?: string;
}
/**
* Nexus Avatar — a person's identity as an image, initials, or icon, with an
* optional presence / verification badge. Built to the design spec
* (instruction/nexus-design-system.md §6.2) and the `--components-avatar-*`
* tokens. The status badge is rendered by the sibling `StatusIcon` component so
* it is never clipped by the avatar's rounded overflow.
*/
export const Avatar = ({
type = "image",
size = "xs",
status = "online",
showStatusIcon = true,
label = "AP",
src,
alt,
changeIcon,
className,
}: AvatarProps) => {
const showStatus = showStatusIcon && Boolean(status);
return (
<span className={cx("relative inline-flex shrink-0", className)}>
<span
className={cx(
"flex items-center justify-center overflow-hidden rounded-full",
"border-[length:var(--stroke-point-eight)] border-[color:var(--components-avatar-color-nuetral-border-default)]",
"bg-[var(--components-avatar-color-nuetral-background-subtle)]",
FRAME_SIZE[size],
)}
{...(type === "image"
? {}
: { role: "img", "aria-label": label ?? "User avatar" })}
>
{type === "image" && (
// eslint-disable-next-line @next/next/no-img-element -- nexus/ is framework-agnostic; no next/image here.
<img src={src} alt={alt ?? ""} className="size-full object-cover" />
)}
{type === "initials" && (
<span
className={cx(
INITIALS_TYPO[size],
"text-[var(--components-avatar-color-nuetral-text-muted)]",
)}
>
{label}
</span>
)}
{type === "icon" &&
(changeIcon ? (
<span className={cx("inline-flex", ICON_FILL)}>{changeIcon}</span>
) : (
<PersonOutlineIcon
width={PERSON_ICON_SIZE[size]}
height={PERSON_ICON_SIZE[size]}
className={ICON_FILL}
/>
))}
</span>
{showStatus && status && (
<StatusIcon
status={status}
size={size}
className="absolute bottom-0 right-0"
/>
)}
</span>
);
};nexus/components/status-icon.tsx
import { VerifiedFillIcon } from "nexus/icons";
export type AvatarSize = "xs" | "sm" | "lg" | "x-lg" | "2x-lg";
export type AvatarStatus = "online" | "offline" | "away" | "busy" | "verified";
// Status badge box size per avatar size (8 / 10 / 12 / 14 / 14px). Token
// utilities exist for 8/10/12; 14 has no spacing token, so it's arbitrary.
const STATUS_BOX: Record<AvatarSize, string> = {
xs: "size-8",
sm: "size-10",
lg: "size-12",
"x-lg": "size-[14px]",
"2x-lg": "size-[14px]",
};
// Solid presence-dot fill per status. Avatar status tokens use capitalized
// names in tokens.css (e.g. --components-avatar-color-status-Online).
const STATUS_DOT: Record<Exclude<AvatarStatus, "verified">, string> = {
online: "bg-[var(--components-avatar-color-status-Online)]",
offline: "bg-[var(--components-avatar-color-status-Offline)]",
away: "bg-[var(--components-avatar-color-status-Away)]",
busy: "bg-[var(--components-avatar-color-status-Busy)]",
};
const STATUS_LABEL: Record<AvatarStatus, string> = {
online: "Online",
offline: "Offline",
away: "Away",
busy: "Busy",
verified: "Verified",
};
const cx = (...classes: (string | false | undefined)[]) =>
classes.filter(Boolean).join(" ");
export interface StatusIconProps {
/** Presence or verification state. */
status: AvatarStatus;
/** Avatar size the badge is attached to — drives the badge diameter. */
size: AvatarSize;
/** Layout-only classes (e.g. absolute positioning from the Avatar). */
className?: string;
}
/**
* The presence / verification badge that sits on an `Avatar`. Presence states
* (online, offline, away, busy) render a solid colour dot; `verified` renders
* the brand seal with a white check (a white backing circle shows through the
* badge's cut-out check). Built to the `--components-avatar-color-status-*`
* tokens.
*/
export const StatusIcon = ({ status, size, className }: StatusIconProps) => {
const box = STATUS_BOX[size];
if (status === "verified") {
return (
<span
role="img"
aria-label={STATUS_LABEL.verified}
// `className` from the Avatar is `absolute bottom-0 right-0`, which pins
// the badge to the avatar corner AND makes it the containing block for
// the white backing below. Do NOT add `relative` here — Tailwind emits
// `.relative` after `.absolute`, so it would win and detach the badge
// from the corner (the bug that pushed verified to the top-right).
className={cx("inline-block", box, className)}
>
{/* White backing so the seal's cut-out check reads white. */}
<span className="absolute left-1/2 top-1/2 size-[40%] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--components-avatar-color-status-background-subtle)]" />
<VerifiedFillIcon className="relative block size-full [&_path]:fill-[var(--components-avatar-color-status-Verification)]" />
</span>
);
}
return (
<span
role="img"
aria-label={`Status: ${STATUS_LABEL[status]}`}
className={cx("inline-flex items-center justify-center", box, className)}
>
<span className={cx("size-[83.333%] rounded-full", STATUS_DOT[status])} />
</span>
);
};