The conventions every component in the registry follows — you will edit the copied files and write your own beside them.
Most exist because a web instinct is wrong here.
1. Declare both axes. Always.
Vela resolves a frame’s size from UDim2.new(0, 0, 0, 0). Nothing infers a size from content unless
you ask, 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 — which 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 collapses the parent.
When a component nests, every level needs an answer. card is the clearest case: every part
carries w-full h-fit.
3. Nothing inherits
There is no cascade: text-sm on a button does not reach the label inside it, because text
properties belong to the instance that draws the text.
So a component with a label needs a second recipe for that label. buttonVariants sizes the
button, and buttonLabelVariants styles the text, both keyed off the same size prop. See
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, and
~/lib/text’s TextSlot picks between them. See Text and labels.
Every text recipe declares a font-* — rule 1 again. Vela leaves FontFace untouched when no
font-* token appears, and Roblox’s 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 its children’s arrangement, so a consumer wanting a different one must replace those classes.
So do not add a wrapper frame just to get a second layout — restructure the parts. Do not add an
instance to carry what an existing instance can carry, which is why the registry has no
AspectRatio component:
<imagelabel className="w-full aspect-video" Image={thumbnail} />That component exists in CSS to hide a padding-bottom: 56.25% trick. Roblox has
UIAspectRatioConstraint and Vela lowers straight onto it. A wrapper would add a frame, a file and
a level to the AutomaticSize chain, and be less capable. The class works on any host instance,
while a component forces a Frame.
Ratio is a class, not a component.
6. Flat named exports
Card, CardHeader, CardTitle — not Card.Root. This is source someone pastes and edits, so
each part reads independently and deletes 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 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 costs one — every export const included. On a Vela version that inlined its runtime into
each file, card’s six exported recipes pushed the module over 200 and it stopped loading:
Out of local registers when trying to allocate CardHeaderNot a compile error — the module failed at load. Six export consts cost six registers, one object
costs one, which is why card and alert both export a single object.
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 larger, still finite, and measurable:
grep -c "^local " out/shared/ui/card.luauA number in the twenties is normal. One near 200 means the next part you add breaks the component.
7. Icons are text glyphs, replaceable by slot
Roblox has no icon font, and shipping images means uploading assets to somebody’s account and owning
the moderation forever. Facet renders ▾, ✓, ✕ as text and exposes the slot, so a project
wanting 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 tooling. If changing your theme base requires editing a component, the 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
Every import a component makes must appear in the registry entry as a dependency or a
registryDependency. An undeclared one ships a file that cannot compile in a project without that
package.
This binds you only if you are running your own registry, where
registry:check catches unresolvable registry dependencies but not a missing npm one.
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. 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, and 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, reaching the primitive as a raw string
prop and dropped in silence.
// resolved<ToggleGroupPrimitive.Root className={className} type="single" {...passthrough} />
// dropped, silently<ToggleGroupPrimitive.Root type="single" {...{ className, ...passthrough }} />toggle-group is where
this bit: 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 a UIListLayout that
owns one. 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 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. Several files add a local
forwardProps that widens the bag and drops children from its type. 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 only build-clean runtime failure in the registry. The component declares it, so facet add can
offer to write it and facet doctor notices 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. It also takes the first
host element under it as the outside-press boundary. A className there fights the first, and
through the UICorner Vela prepends for rounded-* it 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. A class routed
through a wrapper’s className is overwritten by that wrapper’s recipe. dialog spells the prop
overlayClassName and merges it where
the overlay resolves. It is the
TextSlot trap one level up.
Two things about spread order
Neutral defaults first. A bare textbutton is an opaque grey box labelled “Button”, so
BackgroundTransparency, BorderSizePixel, Text and AutoButtonColor are 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, 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.
Readability is a feature
Prefer a readable 60-line component over a clever 20-line one. The consumer reads this code — a copied file that is hard to edit has failed at the one thing the copy-in model is for.