Facetguides

Component conventions

The ten rules every registry component follows — each one correcting a web instinct that is wrong on Roblox.

These are the conventions every component in the registry follows. They matter to you for two reasons: you are going to edit the copied files, and you are going to write components of your own next to them.

Most of them exist because a web instinct is wrong here. Each one says which instinct it corrects.

1. Declare both axes. Always.

Vela resolves a frame’s size starting from UDim2.new(0, 0, 0, 0). Nothing infers a size from content unless you ask for it, and UIPadding does not grow a frame — it insets its children.

So h-9 px-4, a perfectly good shadcn button, renders zero pixels wide. That is not hypothetical; it is what the first published button did.

h-9 px-4 → 0 × 36. Invisible.
h-9 w-fit px-4 → hugs its content. Correct.
h-9 w-9 → fixed square. Also correct.

Every leaf declares a width and a height: a concrete class on each axis, or w-fit / h-fit / size-fit for the axis that should hug.

2. AutomaticSize is a chain

A container set to hug its content can only measure children that already know their own size. One child with an unresolved axis and the parent collapses.

When a component nests — button → label, card → header → title — every level needs an answer, not just the outermost one. card is the clearest example: every part carries w-full h-fit, width from the parent and height from the content, all the way down.

3. Nothing inherits

There is no cascade. text-sm on a button does not reach the label inside it; text properties belong to the instance that draws the text.

The consequence: a component with a label needs a second recipe for that label. buttonVariants sizes the button, buttonLabelVariants styles the text, both keyed off the same size prop. Covered in Text and labels.

4. Text arrives as a prop, renders as a child instance

Text?: string on every component that draws a string; children stays composition. ~/lib/text’s TextSlot picks between them. See Text and labels for the whole story, including why the prop is uppercase and why TextSlot takes no className.

Every text recipe declares a font-* — rule 1 in a different costume. Vela leaves FontFace untouched when no font-* token appears, and Roblox’s untouched default is LegacyArial: a different typeface, not a weight.

5. Layout is an instance, not a property

flex-row, items-*, justify-* and gap-* all lower onto a single UIListLayout child, and one instance can hold one layout.

A component that sets any of them owns the arrangement of its children. A consumer who wants a different one has to replace the component’s layout classes rather than add to them.

Corollary: do not add a wrapper frame just to get a second layout. Restructure the parts instead — which is something you can do freely, because the file is yours.

The general form of that corollary is do not add an instance to carry what an existing instance can carry, and it is why the registry has no AspectRatio component:

<imagelabel className="w-full aspect-video" Image={thumbnail} />

CSS could not constrain a box’s ratio for most of its life, so Radix packaged the padding-bottom: 56.25% workaround and shadcn re-exported it — the component exists to hide a trick. Roblox has UIAspectRatioConstraint and Vela lowers straight onto it (aspect-square, aspect-video, aspect-[4/3], aspect-[1.5]). A wrapper would add a frame to the tree, a file to your project, and a level to the AutomaticSize chain, to deliver something you can already write on the element you have — and it would be strictly less capable, since the class works on any host instance while a component forces whatever it wraps to sit inside a Frame.

Ratio is a class, not a component.

6. Flat named exports

Card, CardHeader, CardTitle — not Card.Root. Lattice uses namespace objects and that is right for a library; this is source someone pastes and edits, so it matches shadcn, where each part reads independently and can be deleted on its own.

Facet components wrap Lattice’s namespaces rather than re-exporting them:

import { Dialog as DialogPrimitive } from "@lattice-ui/react-dialog";
export const Dialog = DialogPrimitive.Root;
export function DialogContent(props: DialogContentProps) { /* styled */ }

7. Icons are text glyphs, replaceable by slot

Roblox has no icon font and no free lunch: shipping images means uploading assets to somebody’s account and owning the moderation and licensing forever. Facet renders , , as text and exposes the slot, so a project that wants real artwork passes its own through children.

8. Roles, never ramp steps

bg-muted, not bg-zinc-800. This is what makes facetTheme({ base: "slate" }) retheme every copied component without editing one of them.

Enforced by review, not by tooling — the class strings are just text. If changing your theme base requires editing a component, this rule was broken somewhere in it.

9. ClassValue is a reserved name

Vela’s inlined runtime declares a local ClassValue, so a component importing that name gets TS2440: Import declaration conflicts with local declaration. ~/lib/utils re-exports it as ClassName. Use that.

10. Every import is declared

An import a component makes must appear in the registry entry as a dependency (npm) or a registryDependency (another item). An undeclared one ships a file that cannot compile in a project that did not happen to have the package already.

This one only binds you if you are running your own registry — but if you are, registry:check catches unresolvable registry dependencies and cannot catch a missing npm one. That part is on the author.

Anatomy

Everything above, in one file:

import { fv, type VariantProps } from "@facet-ui/react-variants";
import { getPassthroughProps, React } from "@lattice-ui/react-runtime";
import { TextSlot } from "~/lib/text";
import { cn } from "~/lib/utils";
// 1. geometry + surface on the root, both axes resolved
export const thingVariants = fv("flex-row items-center w-fit h-9 rounded-md", {
variants: { /* … */ },
});
// 2. a matching recipe for any text this component draws itself
export const thingLabelVariants = fv("font-medium text-foreground", {
variants: { /* … */ },
});
export type ThingProps = VariantProps<typeof thingVariants> & { Text?: string };
const OWN_PROPS = ["variant", "size", "className", "Text", "children"] as const;
// 3. neutralize Roblox's own look, never the consumer's
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0 };
export function Thing(props: ThingProps) {
const passthrough = getPassthroughProps<Frame>(props, OWN_PROPS);
return (
<frame
className={thingVariants({ variant: props.variant, className: props.className })}
{...NEUTRAL_PROPS}
{...passthrough}
>
<TextSlot Text={props.Text} className={thingLabelVariants({ size: props.size })}>
{props.children}
</TextSlot>
</frame>
);
}

Wrapping a Lattice primitive

Everything above holds for a component that is a recipe plus a host element. Twelve of the components added in 0.4.0 sit on a Lattice primitive instead, and three more rules came out of building them — written once here rather than twelve times in the files.

A. State you style by is mirrored, not reached for

Lattice keeps its contexts private. Checkbox.Root knows whether it is checked; nothing outside the primitive can read that. But the border and the fill are the wrapper’s job, so the wrapper needs the same answer.

The way out is not to reach in. Hold the value in the copied file with useControllableState — the same hook the primitive uses — and drive the primitive controlled from it:

const [checked, setChecked] = useControllableState<CheckedState>({
value: props.checked,
defaultValue: props.defaultChecked ?? false,
onChange: props.onCheckedChange,
});
<CheckboxPrimitive.Root checked={checked} onCheckedChange={setChecked} className={…}>

One copy of the state, and it lives in the file you own. Where a component does not style by a state, there is no mirror: progress maps a number to a width and radio-group’s inner dot is mounted and unmounted by the primitive.

The cost is that the mirror only knows what it was told. Tabs.Root falls back to the first enabled trigger when it has no value, and the mirror cannot see that decision — so tabs needs a defaultValue or nothing styles as selected.

B. A className on a primitive call site has to be an attribute

Vela rewrites the call sites it can see. Written as an attribute, className is resolved. Folded into a shared spread, it is just a key in an object — it reaches the primitive as a raw string prop, is dropped in silence, and the component renders unstyled with no diagnostic.

// resolved
<ToggleGroupPrimitive.Root className={className} type="single" {...passthrough} />
// dropped, silently
<ToggleGroupPrimitive.Root type="single" {...{ className, ...passthrough }} />

toggle-group is where this bit, because it renders its root twice and the shared spread was the obvious tidy-up.

C. Where the primitive owns a property, the recipe stays off it

A primitive that writes Size or Position every frame is as load-bearing as the UIListLayout that owns one, and rule 5 applies to both. Three components omit a class they would otherwise be required to have:

ComponentWhat it omitsWho owns it
sliderflex-* on the trackthe range’s fill and the thumb’s travel
switchany position on the thumbSwitch.Thumb, which animates it
textareaa height on the inputTextarea.Input, which grows it by row

The failure mode is the one that looks like it works: a declared width on progress’s indicator renders correctly on mount and is overwritten as soon as the value moves.

D. A forwarded prop bag crosses a boundary widened

toSlotProps is the crossing point. The typed passthrough bag collides with the runtime host’s ref and with primitives that type children as a single element — what asChild merges onto — so several files add a local forwardProps that widens the bag and drops children from its type alone. The bag never carries one; children is always an own prop.

Wrapping a layered primitive

dialog is the first, and three more things came out of it.

A PortalProvider has to be above the app. Dialog.Portal reads a strict context for the BasePlayerGui it renders into. Missing it throws on open rather than rendering nowhere — which is the better of the two failures, and still the only build-clean runtime failure in the registry. The component declares it, so facet add can offer to write it and facet doctor can notice when it goes missing.

The styled panel is a frame inside the primitive’s content, never the content itself. The primitive forces Size on its own host so the layer spans the screen, and takes the first host element under it as the boundary an outside press is measured against. A className there fights the first and — through the UICorner Vela prepends for rounded-* — quietly becomes the second.

A class forwarded to another component has to reach one className expression. Vela resolves a className at the call site and hands the component the resolved properties, so a class routed through a wrapper’s className prop is overwritten by that wrapper’s own recipe. dialog spells the prop overlayClassName and merges it where the overlay actually resolves. It is the TextSlot trap one level up.

Two things about spread order

Neutral defaults first. Roblox instance defaults are themselves a look — a bare textbutton is an opaque grey box labelled “Button”. BackgroundTransparency, BorderSizePixel, Text and AutoButtonColor get cleared before anything visual is applied.

Then consumer passthrough, then behavior props. Behavior is never overridable — event handlers are composed rather than replaced:

Event: composeEvents(passthrough.Event, { Activated: handleActivated })

Appearance is a weaker promise than the ordering suggests. Vela emits a component’s class-derived props after every spread, whatever order the author wrote them in, so a consumer’s passthrough value loses to anything the recipe sets. What survives is any property the classes do not touch — LayoutOrder, Position, Visible, ZIndex. See Overriding from the call site.

Readability is a feature

Prefer a readable 60-line component over a clever 20-line one. The consumer reads this code — it is the product, not an implementation detail. A copied file that is hard to edit has failed at the one thing the copy-in model is for.