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 */ }The parts stay flat, but the recipes do not: a component file exports one recipe object, not one export per part.
Luau allows 200 local registers per function and a module body is a function, so every
module-scope local in the emitted Luau costs one — including every export const, which roblox-ts
lowers to a local plus an assignment. Vela then inlines its whole runtime into any file with a
computed className, and every Facet class string comes out of fv(), so every component file
carries that copy.
In Vela 0.8.0 the copy declared ~96 module-scope locals before the file’s own first line. card —
six exported recipes — went over 200 and stopped loading, with an error naming generated code its
author never wrote:
Out of local registers when trying to allocate CardHeaderNot a compile error; the module simply failed at load. Six export consts cost six registers, one
object costs one — which is why card and alert both export a single object.
Vela 0.9.0 scopes the inlined runtime into one initializer and emitted components now sit around
18–24 module-scope locals, which is why ^0.9.0 is the floor the CLI installs. The headroom is much
larger. It is still finite, and it is measurable:
grep -c "^local " out/shared/ui/card.luauA number in the twenties is normal. A number near 200 means the next part you add to that component is the one that breaks it.
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 resolvedexport const thingVariants = fv("flex-row items-center w-fit h-9 rounded-md", { variants: { /* … */ },});
// 2. a matching recipe for any text this component draws itselfexport 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'sconst 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:
| Component | What it omits | Who owns it |
|---|---|---|
slider | flex-* on the track | the range’s fill and the thumb’s travel |
switch | any position on the thumb | Switch.Thumb, which animates it |
textarea | a height on the input | Textarea.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.