npx facet-rbxts add avatarCopies ui/avatar.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants,
@lattice-ui/react-runtime@^0.8.0 and @lattice-ui/react-avatar@^0.8.0.
import { Avatar, AvatarFallback, AvatarImage } from "../shared/ui/avatar";
<Avatar src={`rbxthumb://type=AvatarHeadShot&id=${userId}&w=150&h=150`}> <AvatarImage /> <AvatarFallback Text="NR" /></Avatar>import React from "@rbxts/react";import { Avatar, AvatarFallback } from "../ui/avatar";import { Label } from "../ui/label";import { MODE } from "../facet-mode";
/** * Fallbacks only, and that is the preview being honest rather than a choice: * `AvatarImage` draws an `rbxassetid://` or `rbxthumb://` URL, and Loom has no * Roblox content pipeline to fetch one from. What this does show is the part * the file owns — the circle, and the initials centred inside it. * * Every circle is the same `size-10`, because that is the only size there is. * A `className` here would be resolved at this call site and then overwritten * by the root recipe's own `size-10 rounded-full`; a different size means * editing the copied file. */export function Party() { return ( <frame className="flex-col gap-2 w-full h-fit"> <frame className="flex-row items-center gap-3 w-full h-fit"> <Avatar> <AvatarFallback Text="NR" /> </Avatar> <Label Text="Nimbus_Rider" /> </frame> <frame className="flex-row items-center gap-3 w-full h-fit"> <Avatar> <AvatarFallback Text="QB" /> </Avatar> <Label Text="QuarryBoss" /> </frame> </frame> );}import { fv, type VariantProps } from "@facet-ui/react-variants";import { Avatar as AvatarPrimitive } from "@lattice-ui/react-avatar";import { getPassthroughProps, type PassthroughProps, React, toSlotProps } from "@lattice-ui/react-runtime";import { type ClassName, cn } from "~/lib/utils";
/** * Lattice's `Avatar.Root` renders no instance — it only tracks whether the * image loaded — so the circle itself is drawn here. The image and the fallback * both fill it and carry their own `rounded-full`: Roblox clips to a rectangle, * so rounding the parent alone would leave square corners poking out of the * circle whenever a child paints. * * `size` is shadcn's three steps — `sm` 24px, `default` 32px, `lg` 40px — and it * has to be passed to the fallback as well as the root, because shadcn changes * the initials' size through a `group-data-[size=sm]` selector and nothing * cascades here. Same shape as `alert`'s `variant`. * * shadcn has three more parts this file does not: `AvatarBadge`, `AvatarGroup` * and `AvatarGroupCount`. All three are positioned by things Vela reports as * having no Roblox equivalent — `absolute` with `right-0 bottom-0` for the * badge, a negative `-space-x-2` for the overlap in the group. A stack of * avatars is expressible, but not by copying those classes over. * * One recipe object rather than three exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const avatarVariants = { root: fv("shrink-0 rounded-full overflow-hidden", { variants: { size: { sm: "size-6", default: "size-8", lg: "size-10" }, }, defaultVariants: { size: "default" }, }), image: fv("aspect-square size-full rounded-full"), // `bg-muted` belongs to the fallback, not the circle. shadcn puts it here so // an avatar with a loaded image has no surface of its own to show through. fallback: fv("size-full rounded-full bg-muted font-normal text-muted-foreground text-center", { variants: { // shadcn shrinks the initials on the small avatar and leaves the other two // at `text-sm`. Nothing cascades here, so `size` reaches this part too. size: { sm: "text-xs", default: "text-sm", lg: "text-sm" }, }, defaultVariants: { size: "default" }, }),};
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
const ROOT_OWN_PROPS = ["src", "delayMs", "size", "className", "children"] as const;const IMAGE_OWN_PROPS = ["className", "children"] as const;const FALLBACK_OWN_PROPS = ["size", "className", "Text", "children"] as const;
export type AvatarProps = VariantProps<typeof avatarVariants.root> & { /** The image source — an `rbxassetid://` or `rbxthumb://` URL. */ src?: string; /** How long to hold the fallback back while the image loads, in milliseconds. */ delayMs?: number; className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type AvatarImageProps = { className?: ClassName } & PassthroughProps<ImageLabel>;export type AvatarFallbackProps = VariantProps<typeof avatarVariants.fallback> & { className?: ClassName; Text?: string;} & PassthroughProps<TextLabel>;
// The forwarded bag is widened by `toSlotProps` and then has `children`// dropped from its *type*: these parts type `children` as a single element// (what `asChild` merges onto), and the bag never actually carries one —// `children` is listed as an own prop — so only the type needs narrowing.function forwardProps(props: object, ownKeys: readonly string[]): { key?: React.Key } & { [index: string]: unknown } { return toSlotProps(getPassthroughProps(props, ownKeys));}
export function Avatar(props: AvatarProps) { return ( <AvatarPrimitive.Root delayMs={props.delayMs} src={props.src}> <frame className={cn(avatarVariants.root({ size: props.size, className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, ROOT_OWN_PROPS)} > {props.children} </frame> </AvatarPrimitive.Root> );}
export function AvatarImage(props: AvatarImageProps) { return ( <AvatarPrimitive.Image className={cn(avatarVariants.image({ className: props.className }))} {...forwardProps(props, IMAGE_OWN_PROPS)} /> );}
export function AvatarFallback(props: AvatarFallbackProps) { return ( <AvatarPrimitive.Fallback className={cn(avatarVariants.fallback({ size: props.size, className: props.className }))} Text={props.Text ?? ""} {...forwardProps(props, FALLBACK_OWN_PROPS)} /> );}The parts
| Part | Renders | Classes |
|---|---|---|
Avatar | Frame | size-10 rounded-full bg-muted overflow-hidden |
AvatarImage | ImageLabel | size-full rounded-full |
AvatarFallback | TextLabel | size-full rounded-full bg-muted text-sm font-medium text-muted-foreground text-center |
Renders a Frame. Unknown props forward onto it and are type-checked against it, so a prop Frame does not accept is a compile error.
That is Avatar. AvatarImage forwards onto an ImageLabel and AvatarFallback onto a TextLabel.
The circle is drawn here, not by the primitive
Avatar.Root renders no instance. It tracks whether the image loaded and hands that answer to
Image and Fallback; it draws nothing. So unlike every other component in this tier, the visible
container is a plain <frame> in the copied file, and Avatar.Root wraps it.
That inverts the usual nesting:
<AvatarPrimitive.Root delayMs={props.delayMs} src={props.src}> <frame className={cn(avatarVariants.root({ className: props.className }))} …> {props.children} </frame></AvatarPrimitive.Root>Progress is built the same way, for the same reason.
Every part rounds itself
rounded-full appears three times — on the root, on the image and on the fallback — and it is not
redundant. A UICorner rounds the instance it is on; it does not clip children to that shape.
Round only the parent and a child that paints its own background pokes square corners out of the
circle.
overflow-hidden on the root is ClipsDescendants, which clips to the root’s rectangle — not to
its corner radius. It keeps a too-large image inside the box; it does not round it.
Props
Avatar
| Prop | Type | Description |
|---|---|---|
| src | string | The image source — an rbxassetid:// or rbxthumb:// URL. Read by the primitive, which decides whether the image or the fallback shows. |
| delayMs | number | How long to hold the fallback back while the image loads. Stops a flash of initials on a fast load. |
| children | React.ReactNode | AvatarImage and AvatarFallback, in that order. |
| className | ClassName | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
AvatarFallback takes Text and className; AvatarImage takes className.
size-10 is the only size there is
There is no size variant. size-10 is stated on the root recipe, and a className at the call
site cannot change it — a class written there resolves to Size at that call site and the recipe
overwrites it, which is
measured, not predicted.
A bigger avatar is an edit to the copied file:
export const avatarVariants = { root: fv("size-14 rounded-full bg-muted overflow-hidden"), …};Or a variant, if a project needs two of them — which is what the file is for.