Facetguides

Component conventions

The ten rules every registry component follows, each correcting a web instinct.

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 */ }

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

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