Latticecomponents

Avatar

An image-with-fallback primitive that tracks load status and debounces the fallback.

@lattice-ui/react-avatarStable directionimport Avatardepends on runtime

Avatar shows a player or entity image, falling back to placeholder content when there is no source or the image has not loaded. It tracks the underlying ImageLabel’s load lifecycle, so a fast load never flashes a placeholder and a broken asset never leaves an empty box.

Preview

Edit

Import

import { Avatar } from "@lattice-ui/react-avatar";

Anatomy

The root owns the source and status. The image shows once loaded, and the fallback otherwise. Root renders no instance, so both parts mount directly into whatever parent you place the avatar in.

Avatar anatomy

Avatar anatomy
<Avatar.Root src="...">
<Avatar.Image />
<Avatar.Fallback />
</Avatar.Root>
PartRequiredResponsibility
Avatar.RootyesOwns the source, load status, and the fallback delay; shares them through context.
Avatar.ImageyesRenders the image and reports its load status back to the root.
Avatar.FallbacknoPlaceholder shown while the image is missing, loading past the delay, or errored.

Examples

Basic image with initials fallback

A source on the root, the default circular image, and an initials fallback. The fallback uses asChild to render your own label, since the default is a placeholder textlabel that real content should replace rather than sit inside.

BasicAvatar.tsx
import { Avatar } from "@lattice-ui/react-avatar";
export function BasicAvatar() {
return (
<Avatar.Root src="rbxassetid://1234567890">
<Avatar.Image />
<Avatar.Fallback asChild>
<textlabel
BackgroundColor3={Color3.fromRGB(65, 72, 89)}
BorderSizePixel={0}
Size={UDim2.fromOffset(40, 40)}
Text="MJ"
TextColor3={Color3.fromRGB(235, 240, 248)}
TextSize={14}
>
<uicorner CornerRadius={new UDim(1, 0)} />
</textlabel>
</Avatar.Fallback>
</Avatar.Root>
);
}

Tuning the fallback delay

delayMs controls how long a loading avatar stays blank before the fallback appears. The default 250 absorbs most fast loads without a flash. 0 shows the fallback the moment loading starts. The delay only applies while loading — an empty src or an errored load shows the fallback immediately.

LoadoutSlotAvatar.tsx
import { Avatar } from "@lattice-ui/react-avatar";
export function LoadoutSlotAvatar(props: { iconSrc?: string }) {
return (
<Avatar.Root src={props.iconSrc} delayMs={400}>
<Avatar.Image />
<Avatar.Fallback asChild>
<textlabel
BackgroundColor3={Color3.fromRGB(65, 72, 89)}
BorderSizePixel={0}
Size={UDim2.fromOffset(40, 40)}
Text="?"
TextColor3={Color3.fromRGB(235, 240, 248)}
TextSize={16}
>
<uicorner CornerRadius={new UDim(1, 0)} />
</textlabel>
</Avatar.Fallback>
</Avatar.Root>
);
}

Player headshot from a UserId

The most common source in a Roblox game is a rbxthumb:// headshot URL built from a UserId. The engine resolves and caches it, Avatar.Image reports loaded once the ImageLabel finishes, and the initials cover the gap for slow or unavailable thumbnails.

PlayerHeadshot.tsx
import { Avatar } from "@lattice-ui/react-avatar";
export function PlayerHeadshot(props: { userId: number; displayName: string }) {
const src = `rbxthumb://type=AvatarHeadShot&id=${props.userId}&w=150&h=150`;
const initials = props.displayName.sub(1, 2).upper();
return (
<Avatar.Root src={src}>
<Avatar.Image />
<Avatar.Fallback asChild>
<textlabel
BackgroundColor3={Color3.fromRGB(65, 72, 89)}
BorderSizePixel={0}
Size={UDim2.fromOffset(40, 40)}
Text={initials}
TextColor3={Color3.fromRGB(235, 240, 248)}
TextSize={14}
>
<uicorner CornerRadius={new UDim(1, 0)} />
</textlabel>
</Avatar.Fallback>
</Avatar.Root>
);
}

Status ring composition

The primitive handles only the image-versus-fallback handoff, so a status ring is your own frame composed around it. Because Root renders no instance, both parts mount straight into the ring frame.

StatusAvatar.tsx
import { Avatar } from "@lattice-ui/react-avatar";
export function StatusAvatar(props: { userId: number; online: boolean }) {
const src = `rbxthumb://type=AvatarHeadShot&id=${props.userId}&w=150&h=150`;
const statusColor = props.online ? Color3.fromRGB(64, 200, 120) : Color3.fromRGB(96, 104, 122);
return (
<frame BackgroundTransparency={1} Size={UDim2.fromOffset(48, 48)}>
<uicorner CornerRadius={new UDim(1, 0)} />
<uistroke Color={statusColor} Thickness={2} />
<Avatar.Root src={src}>
<Avatar.Image asChild>
<imagelabel
AnchorPoint={new Vector2(0.5, 0.5)}
BackgroundTransparency={1}
BorderSizePixel={0}
Position={UDim2.fromScale(0.5, 0.5)}
Size={UDim2.fromOffset(40, 40)}
>
<uicorner CornerRadius={new UDim(1, 0)} />
</imagelabel>
</Avatar.Image>
<Avatar.Fallback asChild>
<frame
AnchorPoint={new Vector2(0.5, 0.5)}
BackgroundColor3={Color3.fromRGB(65, 72, 89)}
BorderSizePixel={0}
Position={UDim2.fromScale(0.5, 0.5)}
Size={UDim2.fromOffset(40, 40)}
>
<uicorner CornerRadius={new UDim(1, 0)} />
</frame>
</Avatar.Fallback>
</Avatar.Root>
<frame
AnchorPoint={new Vector2(1, 1)}
BackgroundColor3={statusColor}
BorderSizePixel={0}
Position={UDim2.fromScale(1, 1)}
Size={UDim2.fromOffset(12, 12)}
>
<uicorner CornerRadius={new UDim(1, 0)} />
<uistroke Color={Color3.fromRGB(24, 26, 32)} Thickness={2} />
</frame>
</frame>
);
}

Avatar stack

Negative uilistlayout padding pulls each wrapper over the previous one. Descending ZIndex keeps the leftmost on top, and a stroke in the panel’s background colour cuts them apart. Each member gets their own Avatar.Root, so slow thumbnails resolve independently.

PartyStack.tsx
import { Avatar } from "@lattice-ui/react-avatar";
const PARTY = [
{ userId: 156, initials: "BH" },
{ userId: 261, initials: "SH" },
{ userId: 1179762, initials: "JN" },
];
export function PartyStack() {
return (
<frame BackgroundTransparency={1} Size={UDim2.fromOffset(160, 40)}>
<uilistlayout
FillDirection={Enum.FillDirection.Horizontal}
Padding={new UDim(0, -12)}
SortOrder={Enum.SortOrder.LayoutOrder}
/>
{PARTY.map((member, index) => (
<frame
key={member.userId}
BackgroundColor3={Color3.fromRGB(24, 26, 32)}
BorderSizePixel={0}
LayoutOrder={index}
Size={UDim2.fromOffset(40, 40)}
ZIndex={PARTY.size() - index}
>
<uicorner CornerRadius={new UDim(1, 0)} />
<uistroke Color={Color3.fromRGB(24, 26, 32)} Thickness={2} />
<Avatar.Root src={`rbxthumb://type=AvatarHeadShot&id=${member.userId}&w=150&h=150`}>
<Avatar.Image />
<Avatar.Fallback asChild>
<textlabel
BackgroundColor3={Color3.fromRGB(65, 72, 89)}
BorderSizePixel={0}
Size={UDim2.fromOffset(40, 40)}
Text={member.initials}
TextColor3={Color3.fromRGB(235, 240, 248)}
TextSize={13}
>
<uicorner CornerRadius={new UDim(1, 0)} />
</textlabel>
</Avatar.Fallback>
</Avatar.Root>
</frame>
))}
</frame>
);
}

How it behaves

Load status

Root tracks an AvatarStatus of "idle", "loading", "loaded" or "error". On mount, and whenever src changes, it enters "loading" for a non-empty source or "error" otherwise. "idle" exists in the union, but the built-in parts never produce it. Changing src restarts the cycle, with a sequence guard so a stale timer cannot fire.

Avatar.Image checks IsLoaded immediately and subscribes to its changed signal. It sets the status to "loaded" once the engine finishes, or "error" when its resolved source is empty. It is only Visible while "loaded", so a broken or in-flight asset never shows as an empty box.

Source resolution

Avatar.Image resolves its source from its own src first, then the root’s — so set it once on the root, or override per image.

Fallback timing

Avatar.Fallback is hidden once "loaded", always shown on "error", and otherwise shown only after the delay elapses. The root starts a delayMs timer when a source begins loading, so a fast load never flashes a placeholder. With no source the delay counts as elapsed immediately.

This rule is exported as resolveAvatarFallbackVisible(status, delayElapsed) alongside the AvatarStatus type, so custom status-driven parts can share the exact same visibility logic.

Default parts and asChild

Root renders no instance, so Image and Fallback mount into the surrounding parent and you control layout from outside. The defaults are a 40x40 circular imagelabel and a matching textlabel with placeholder "AB" text. Children passed without asChild render inside that label rather than replacing it.

With asChild, Avatar.Image merges the resolved Image source, load-bound Visible, and its status-tracking ref onto your single child element, and Avatar.Fallback merges only the derived Visible. Both parts error if asChild is set without a child.

API reference

Avatar.Root

PropTypeDescription
srcstringDefault image source shared with Avatar.Image through context. An empty or absent source resolves to the error status and shows the fallback immediately.
delayMsnumberMilliseconds to wait before showing the fallback while loading, so fast loads do not flash a placeholder. Defaults to 250 and is clamped to a minimum of 0.
childrenReact.ReactNodeThe image and fallback parts. The root renders no instance of its own, so children mount into the surrounding parent.

Avatar.Image

PropTypeDescription
srcstringImage source for this part. Overrides the root's src when set; otherwise the root's src is used.
asChildbooleanMerge the resolved source, load-bound visibility, and status-tracking ref onto the single child element instead of rendering the default 40x40 circular imagelabel. The child must be an imagelabel.
childrenReact.ReactElementThe element to render. Required when asChild is set.

Avatar.Fallback

PropTypeDescription
asChildbooleanMerge the derived visibility onto the single child element instead of rendering the default 40x40 circular textlabel.
childrenReact.ReactElementThe placeholder element to render, such as initials or an icon. Required when asChild is set; otherwise rendered inside the default label on top of its placeholder text.