@lattice-ui/react-avatarStable directionimport Avatardepends on runtimeAvatar represents a player or entity with an image, falling back to placeholder content when there is no source or the image has not loaded. It tracks the load lifecycle of the underlying ImageLabel and coordinates when the image versus the fallback is shown, so you never flash a placeholder during a fast load or leave an empty box on a broken asset.
Reach for Avatar wherever you show a thumbnail that might be missing or slow — headshots, group icons, item images — and want the image-then-fallback handoff handled for you, including a short delay before the fallback appears so quick loads stay seamless.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
import { Avatar } from "@lattice-ui/react-avatar";import { React } from "@lattice-ui/react-runtime";import { Text, useTheme } from "@lattice-ui/react-style";
type Member = { initials: string; name: string; role: string; accent?: boolean;};
function AvatarExample() { const { theme } = useTheme();
const members: Array<Member> = [ { initials: "AC", name: "Ava Chen", role: "Design lead", accent: true }, { initials: "NP", name: "Noah Park", role: "Engineer" }, { initials: "MT", name: "Mia Torres", role: "Product" }, ];
return ( <frame BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)}> <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[16])} PaddingLeft={new UDim(0, theme.space[20])} PaddingRight={new UDim(0, theme.space[20])} PaddingTop={new UDim(0, theme.space[16])} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[10])} />
<frame BackgroundTransparency={1} LayoutOrder={0} Size={UDim2.fromOffset(260, 40)}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamBold} Size={UDim2.fromOffset(260, 18)} Text="Project members" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.bodyMd.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(0, 22)} Size={UDim2.fromOffset(260, 16)} Text="3 people · 2 online" TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame>
{members.map((member, index) => ( <frame BackgroundTransparency={1} key={member.initials} LayoutOrder={index + 1} Size={UDim2.fromOffset(260, 40)} > <Avatar.Root> <frame BackgroundColor3={member.accent ? theme.colors.accent : theme.colors.surface} BorderSizePixel={0} Position={UDim2.fromOffset(0, 2)} Size={UDim2.fromOffset(36, 36)} > <uicorner CornerRadius={new UDim(1, 0)} /> <uistroke Color={member.accent ? theme.colors.accent : theme.colors.border} Thickness={1} /> <Avatar.Fallback asChild> <textlabel BackgroundTransparency={1} BorderSizePixel={0} Font={Enum.Font.GothamMedium} Size={UDim2.fromScale(1, 1)} Text={member.initials} TextColor3={member.accent ? theme.colors.accentContrast : theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} /> </Avatar.Fallback> </frame> </Avatar.Root> <Text BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Position={UDim2.fromOffset(48, 2)} Size={UDim2.fromOffset(212, 18)} Text={member.name} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(48, 22)} Size={UDim2.fromOffset(212, 16)} Text={member.role} TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame> ))} </frame> );}Import
import { Avatar } from "@lattice-ui/react-avatar";Anatomy
Compose Root around an Image and a Fallback. The root owns the source and status; the image shows once loaded and the fallback shows otherwise. The root itself renders no instance — it is a pure context provider — so both parts mount directly into whatever parent you place the avatar in.
Avatar anatomy
<Avatar.Root src="..."> <Avatar.Image /> <Avatar.Fallback /></Avatar.Root>| Part | Required | Responsibility |
|---|---|---|
Avatar.Root | yes | Owns the source, load status, and the fallback delay; shares them through context. |
Avatar.Image | yes | Renders the image and reports its load status back to the root. |
Avatar.Fallback | no | Placeholder shown while the image is missing, loading past the delay, or errored. |
Examples
Basic image with initials fallback
The smallest useful avatar: a source on the root, the default 40x40 circular image, and an initials fallback. The fallback uses asChild to render your own label — the default fallback is a placeholder textlabel with "AB" text, so real content should replace it rather than sit inside it.
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 is 250, which absorbs most fast loads without a placeholder flash; raise it when your art direction prefers a longer blank window, or set it to 0 to show the fallback the moment loading starts. The delay only applies while loading — an empty or absent src skips it and shows the fallback immediately, and an errored load always shows it.
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: a rbxthumb:// headshot URL built from a UserId. The engine resolves and caches the thumbnail itself, Avatar.Image reports loaded once the ImageLabel finishes, and the initials cover the gap for players whose thumbnails are slow or unavailable.
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
Presence indicators are app-owned visuals: the primitive handles only the image-versus-fallback handoff, so a status ring is just your own frame composed around it. Because Avatar.Root renders no instance, both parts mount straight into the ring frame; asChild on the image lets you center it inside the slightly larger wrapper.
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
A party-members row with overlapping avatars. Negative uilistlayout padding pulls each wrapper over the previous one, descending ZIndex keeps the leftmost member on top, and a stroke in the panel’s background color creates the separation cut. Each member gets their own Avatar.Root, so slow thumbnails resolve independently.
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
Avatar.Root tracks an AvatarStatus of "idle", "loading", "loaded", or "error". On mount and whenever src changes, it enters "loading" if a non-empty source is set, or "error" if the source is empty or absent — the "idle" member exists in the union but the built-in parts never produce it. Changing src restarts the cycle: status resets to "loading" and the fallback delay timer starts over, with a sequence guard so a stale timer from a previous source cannot fire.
Avatar.Image reports the actual load result. It checks its ImageLabel’s IsLoaded property immediately and subscribes to GetPropertyChangedSignal("IsLoaded"), setting the shared status to "loaded" once the engine finishes the asset; if its resolved source is empty it reports "error" instead. The image is only Visible while the status is "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 prop first, then falls back to the root’s src. Set the source once on the root for the common case, or override it per image when one avatar composition needs a different asset than the shared context.
Fallback timing
Avatar.Fallback derives visibility from the status and the delay: hidden once "loaded", always shown on "error", and otherwise shown only after the delay has elapsed. The root starts a delayMs timer (default 250, clamped to a minimum of 0) when a source begins loading; until it fires, the fallback stays hidden so a fast load never flashes a placeholder. When there is no source, the delay is treated as elapsed immediately and the fallback appears at once.
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
Avatar.Root renders no instance of its own — it only provides context — so Image and Fallback mount directly into the surrounding parent and you control layout entirely from outside. The default Avatar.Image is a 40x40 circular imagelabel with a transparent background; the default Avatar.Fallback is a 40x40 circular textlabel with placeholder "AB" text, and 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.
Keep Avatar.Image and Avatar.Fallback mounted as siblings at the same time. Visibility of each is driven by the shared status — the image hides itself until loaded and the fallback hides itself until needed — so you should not conditionally mount one or the other yourself.
Avatar.Image narrows its ref with IsA("ImageLabel") before watching IsLoaded. If your asChild child is any other class — including an imagebutton — load tracking never attaches, the status never reaches "loaded", and the image stays invisible. Project onto an imagelabel only.
Without asChild, Avatar.Fallback renders a textlabel whose Text is "AB" and puts your children inside it — custom content will sit on top of that placeholder text. For real fallback visuals (initials, an icon), pass your element with asChild so it replaces the default label.
Status comes from the ImageLabel.IsLoaded signal, so it reflects the engine’s own asset pipeline. Pass a resolved asset string — a rbxassetid:// id, a rbxthumb:// URL, or the result of Players.GetUserThumbnailAsync — as src. An empty string is treated as an error and shows the fallback immediately.
API reference
Avatar.Root
| Prop | Type | Description |
|---|---|---|
| src | string | Default image source shared with Avatar.Image through context. An empty or absent source resolves to the error status and shows the fallback immediately. |
| delayMs | number | Milliseconds 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. |
| children | React.ReactNode | The image and fallback parts. The root renders no instance of its own, so children mount into the surrounding parent. |
Avatar.Image
| Prop | Type | Description |
|---|---|---|
| src | string | Image source for this part. Overrides the root's src when set; otherwise the root's src is used. |
| asChild | boolean | Merge 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. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Avatar.Fallback
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the derived visibility onto the single child element instead of rendering the default 40x40 circular textlabel. |
| children | React.ReactElement | The 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. |