Avatar
A user's profile picture, with a graceful fallback. When the image is missing or fails to load, the avatar shows initials or a glyph on a stable, accent-independent swatch — no layout shift, no broken-image icon.
Basic
Compose Avatar.Root (which sets the footprint) with an Avatar.Image and an Avatar.Fallback. Base UI shows the fallback until the image loads, and swaps back to it if the load errors — so the box is always filled. Give the image a meaningful alt, and the fallback a seed (a name, id, or email) to pick a stable colour.
"use client";
import { Avatar } from "@stridge/noctis";
export default function AvatarBasic() {
return (
<Avatar.Root>
<Avatar.Image src="https://i.pravatar.cc/96?img=12" alt="Ada Lovelace" width={96} height={96} />
<Avatar.Fallback seed="Ada Lovelace">AL</Avatar.Fallback>
</Avatar.Root>
);
}
Sizes
size sets the edge length of the box — xs, sm, md (the default), lg, or xl. The initials type scales with it. Avatars use their own footprint scale, not the control-height ladder — an avatar is an image, not a control.
"use client";
import { Avatar } from "@stridge/noctis";
const SIZES: Avatar.Size[] = ["2xs", "xs", "sm", "md", "lg", "xl"];
export default function AvatarSizes() {
return (
<div className="flex items-end gap-4">
{SIZES.map((size) => (
<Avatar.Root key={size} size={size}>
<Avatar.Image src={`https://i.pravatar.cc/128?img=5`} alt="Grace Hopper" width={128} height={128} />
<Avatar.Fallback seed="Grace Hopper">GH</Avatar.Fallback>
</Avatar.Root>
))}
</div>
);
}
Shapes
shape is an explicit variant — a round circle (the default) or a rounded-corner box (rounded). The rounded shape reads the radius knob on the capped md step, so it tracks the system radius — softly rounded under the pill default, square when the knob is dialled to 0.
"use client";
import { Avatar } from "@stridge/noctis";
const SHAPES: Avatar.Shape[] = ["circle", "rounded"];
export default function AvatarShapes() {
return (
<div className="flex items-center gap-4">
{SHAPES.map((shape) => (
<Avatar.Root key={shape} size="lg" shape={shape}>
<Avatar.Image src="https://i.pravatar.cc/96?img=32" alt="Katherine Johnson" width={96} height={96} />
<Avatar.Fallback seed="Katherine Johnson">KJ</Avatar.Fallback>
</Avatar.Root>
))}
</div>
);
}
Ring
Set ring for a hairline that follows the shape — it separates an avatar from a same-toned surface. The ring is a box-shadow (not an outline), so it stays round on a circle and rounded on a rounded box.
"use client";
import { Avatar } from "@stridge/noctis";
export default function AvatarRing() {
return (
<div className="flex items-center gap-4">
{/* `ring` draws a hairline that follows the shape — useful against a same-toned surface. */}
<Avatar.Root size="lg" ring>
<Avatar.Image src="https://i.pravatar.cc/96?img=12" alt="" width={96} height={96} />
<Avatar.Fallback seed="Ada Lovelace">AL</Avatar.Fallback>
</Avatar.Root>
<Avatar.Root size="lg" shape="rounded" ring>
<Avatar.Image src="https://i.pravatar.cc/96?img=5" alt="" width={96} height={96} />
<Avatar.Fallback seed="Grace Hopper">GH</Avatar.Fallback>
</Avatar.Root>
</div>
);
}
Fallback colours
With no image, the fallback paints initials on a static swatch chosen by hashing the seed — so the same person always gets the same hue. The palette is accent-independent and carries its own light/dark values, so identity stays stable across themes. Omit seed for a neutral muted fill.
"use client";
import { Avatar } from "@stridge/noctis";
const PEOPLE = [
{ name: "Ada Lovelace", initials: "AL" },
{ name: "Grace Hopper", initials: "GH" },
{ name: "Katherine Johnson", initials: "KJ" },
{ name: "Radia Perlman", initials: "RP" },
{ name: "Barbara Liskov", initials: "BL" },
];
export default function AvatarFallback() {
return (
<div className="flex items-center gap-4">
{PEOPLE.map((person) => (
<Avatar.Root key={person.name} size="lg">
<Avatar.Fallback seed={person.name}>{person.initials}</Avatar.Fallback>
</Avatar.Root>
))}
</div>
);
}
Avatar item
The most common avatar pattern is an avatar beside a visible name — a roster row or a comment author. Here the avatar is decorative: give Avatar.Image an empty alt and add no role or label, so the name announces once instead of twice. This is the default; see Accessibility for the full contract.
- ALAda LovelaceMaintainer
- GHGrace HopperReviewer
"use client";
import { Avatar } from "@stridge/noctis";
const PEOPLE = [
{ seed: "Ada Lovelace", initials: "AL", img: 12, name: "Ada Lovelace", role: "Maintainer" },
{ seed: "Grace Hopper", initials: "GH", img: 5, name: "Grace Hopper", role: "Reviewer" },
];
export default function AvatarItem() {
return (
<ul className="flex flex-col gap-3">
{PEOPLE.map((p) => (
<li key={p.seed} className="flex items-center gap-3">
{/*
* Decorative case: the visible name is the accessible label, so the image takes an
* empty `alt` and the avatar gets no role — the name announces once, not twice.
*/}
<Avatar.Root size="sm">
<Avatar.Image src={`https://i.pravatar.cc/64?img=${p.img}`} alt="" width={64} height={64} />
<Avatar.Fallback seed={p.seed}>{p.initials}</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">{p.name}</span>
<span className="text-mini text-muted">{p.role}</span>
</div>
</li>
))}
</ul>
);
}
Presence
Avatar.Indicator adds a corner dot for availability — online, busy, focus, away, or offline. Each preset picks both the colour (a dedicated --noctis-color-presence-* role) and a default localized screen-reader label, so the dot is never a colour-only signal. A canvas-coloured ring keeps it legible against any photo; an offline teammate is often paired with the dimmed disabled state.
"use client";
import { Avatar } from "@stridge/noctis";
const PRESENCE: { preset: "online" | "busy" | "focus" | "away" | "offline"; seed: string; initials: string }[] = [
{ preset: "online", seed: "Ada Lovelace", initials: "AL" },
{ preset: "busy", seed: "Grace Hopper", initials: "GH" },
{ preset: "focus", seed: "Katherine Johnson", initials: "KJ" },
{ preset: "away", seed: "Joan Clarke", initials: "JC" },
{ preset: "offline", seed: "Radia Perlman", initials: "RP" },
];
export default function AvatarPresence() {
return (
<div className="flex items-center gap-4">
{PRESENCE.map(({ preset, seed, initials }) => (
// The offline teammate is also dimmed (`disabled`) — the dot still reads.
<Avatar.Root key={preset} size="lg" disabled={preset === "offline"}>
<Avatar.Fallback seed={seed}>{initials}</Avatar.Fallback>
<Avatar.Indicator preset={preset} />
</Avatar.Root>
))}
</div>
);
}
Status
Status is a separate concept from presence — a contextual badge (approved, declined, locked), typically placed in the top corner with placement="top". It reuses the system's success/danger/muted roles. Override the announced text with aria-label when the preset's default label doesn't fit the context.
"use client";
import { Avatar } from "@stridge/noctis";
const STATUS: { preset: "approved" | "declined" | "locked"; seed: string; initials: string }[] = [
{ preset: "approved", seed: "Ada Lovelace", initials: "AL" },
{ preset: "declined", seed: "Grace Hopper", initials: "GH" },
{ preset: "locked", seed: "Katherine Johnson", initials: "KJ" },
];
export default function AvatarStatus() {
return (
<div className="flex items-center gap-4">
{STATUS.map(({ preset, seed, initials }) => (
// Status is a separate concept from presence: a contextual badge in the top corner.
<Avatar.Root key={preset} size="lg">
<Avatar.Fallback seed={seed}>{initials}</Avatar.Fallback>
<Avatar.Indicator preset={preset} placement="top" />
</Avatar.Root>
))}
</div>
);
}
Group
Avatar.Group stacks avatars into a facepile — overlapped, each with a canvas-coloured separating ring so it reads against the next. Set size/shape on the group and every child inherits them. Past max avatars, the rest collapse into a +N overflow chip (announced as "N more members"). The chip is non-interactive — Avatar represents avatars, not controls — so to make the overflow reveal the hidden members, wrap the group in your own trigger. Overlap leans the correct way in RTL automatically; spacing (cozy by default, or tight) tunes how far they overlap.
"use client";
import { Avatar } from "@stridge/noctis";
const MEMBERS = [
{ seed: "Ada Lovelace", initials: "AL", img: 12 },
{ seed: "Grace Hopper", initials: "GH", img: 5 },
{ seed: "Katherine Johnson", initials: "KJ", img: 32 },
{ seed: "Joan Clarke", initials: "JC", img: 47 },
{ seed: "Radia Perlman", initials: "RP", img: 16 },
{ seed: "Barbara Liskov", initials: "BL", img: 24 },
{ seed: "Karen Spärck Jones", initials: "KS", img: 9 },
];
export default function AvatarGroupExample() {
return (
<Avatar.Group max={4} aria-label="Project members">
{MEMBERS.map((m) => (
<Avatar.Root key={m.seed}>
<Avatar.Image src={`https://i.pravatar.cc/96?img=${m.img}`} alt="" width={96} height={96} />
<Avatar.Fallback seed={m.seed}>{m.initials}</Avatar.Fallback>
</Avatar.Root>
))}
</Avatar.Group>
);
}
As a trigger
A clickable avatar — the account menu being the canonical case — must be a real <button>. Render Avatar.Root as one (so focus, role, and keyboard activation come from the element) and use it as a Menu.Trigger; it shows a focus-visible ring that follows its shape. Mark the active account with data-selected for the accent selection ring.
"use client";
import { Avatar, Menu } from "@stridge/noctis";
export default function AvatarAsTrigger() {
return (
<Menu.Root>
{/*
* A clickable avatar is a real <button> — render the Root as one so focus, role, and keyboard
* activation come from the element. It shows a focus-visible ring (try Tab) following its shape.
*/}
<Menu.Trigger
render={
<Avatar.Root render={<button type="button" aria-label="Open account menu" />}>
<Avatar.Image src="https://i.pravatar.cc/96?img=12" alt="" width={96} height={96} />
<Avatar.Fallback seed="Ada Lovelace">AL</Avatar.Fallback>
</Avatar.Root>
}
/>
<Menu.Content>
<Menu.Item>
<span className="flex items-center gap-2">
{/* The active account carries the accent selection ring (`data-selected`). */}
<Avatar.Root size="2xs" data-selected>
<Avatar.Fallback seed="Ada Lovelace">AL</Avatar.Fallback>
</Avatar.Root>
Ada Lovelace
</span>
</Menu.Item>
<Menu.Item>
<span className="flex items-center gap-2">
<Avatar.Root size="2xs">
<Avatar.Fallback seed="Grace Hopper">GH</Avatar.Fallback>
</Avatar.Root>
Grace Hopper
</span>
</Menu.Item>
<Menu.Separator />
<Menu.Item>Sign out</Menu.Item>
</Menu.Content>
</Menu.Root>
);
}
Loading
Avatar.Skeleton is an opt-in placeholder shown only while an Avatar.Image is actively loading — a muted pulse, distinct from the permanent initials fallback (shown on error or when there's no image). It clears once the image loads, which cross-fades in. The pulse drops to a static fill under reduced motion. Use Reload image to refetch: a cached image resolves instantly and skips the placeholder, so the demo cache-busts each load to make the loading window visible.
"use client";
import { Avatar, Button } from "@stridge/noctis";
import { useState } from "react";
export default function AvatarLoading() {
const [reload, setReload] = useState(0);
// A unique query per reload forces an uncached fetch, so the loading window — and the skeleton — is
// actually visible. A cached image resolves instantly (Base UI's fast path) and skips the placeholder.
const src = `https://i.pravatar.cc/1000?img=24&reload=${reload}`;
return (
<div className="flex flex-col items-center gap-4">
{/* Re-keying the Root remounts it, so each reload replays idle → loading (skeleton) → cross-fade. */}
<Avatar.Root key={reload} size="xl">
<Avatar.Image src={src} alt="" width={1000} height={1000} />
<Avatar.Fallback seed="Barbara Liskov">BL</Avatar.Fallback>
<Avatar.Skeleton />
</Avatar.Root>
<Button variant="secondary" size="sm" onClick={() => setReload((n) => n + 1)}>
Reload image
</Button>
</div>
);
}
Accessibility
Avatars are two-mode, and choosing the right mode avoids double-announcement:
- Decorative (the default — an avatar beside a visible name, as in Avatar item): give
Avatar.Imagean emptyaltand add norole/label. The adjacent name is the accessible label. - Informative (a standalone avatar with no surrounding text, e.g. an account button): pass
aria-labeltoAvatar.Root. It then stampsrole="img"and announces itself once; keep the inner image'saltempty.
Avatar.Indicator always renders a visually-hidden label (Online, Approved, …) so the dot is announced — a colour-only signal is invisible to assistive tech. Override it with aria-label. Avatar.Group is a role="group" and requires an aria-label; its +N overflow chip carries a visually-hidden "N more members" so the count isn't silently dropped (the chip is non-interactive — wrap the group in your own trigger to make the hidden members reachable). A clickable avatar is always a real <button> (see As a trigger) — never an aria-hidden or div with a click handler.
Anatomy
Compose the avatar from its three parts. Avatar.Root is a Base UI Avatar, so the image-to-fallback swap on a missing or failed load comes for free.
Avatar.Root— the box; owns the sharedsizeandshape, plus theringanddisabledflags. Passaria-labelto make it informative (role="img").Avatar.Image— the profile photo. Crops to cover the box and follows its shape; passsrcandalt(empty in the decorative case). Cross-fades in on load.Avatar.Fallback— shown when no image loads. Pass initials or a glyph as children, and aseedto pick a stablebg-avatar-Nswatch.Avatar.Indicator— a corner presence/status dot. Pass apresetand an optionalplacement; it announces a localized label.Avatar.Skeleton— an opt-in loading placeholder, shown only while an image loads.Avatar.Group— a facepile of overlapped avatars; renders anAvatar.GroupOverflow+Nchip past itsmax.
Every rendered part carries a data-slot (noctis-avatar on the box, noctis-avatar-image on the photo, noctis-avatar-fallback on the fallback, noctis-avatar-indicator on the dot, noctis-avatar-skeleton on the placeholder, noctis-avatar-group on the facepile) for host-side styling — pair it with the data-size/data-shape axes the box stamps, the data-avatar-index the fallback carries, and the data-preset/data-placement the indicator carries.
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every avatar in that region retunes — e.g. .roster { --noctis-avatar-size: 2rem; } shrinks the avatars beneath it. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; the Root and Image forward the Base UI Avatar props they own. Expand a row for the full type and description.