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. 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. That 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.