Latticeguides

Styling with Vela

Style headless Lattice primitives with Tailwind-shaped class names using vela-rbxts, the compile-time className transform Lattice's asChild was built to accept.

Styling with recipes covers the two tools @lattice-ui/react-style ships — sx for a one-off and createRecipe for a variant system. Both resolve at runtime, both read the current Theme, and both hand you a props object.

There is a third option, and it is not ours: vela-rbxts is a roblox-ts transformer that turns Tailwind-shaped className strings into Roblox properties and helper instances at compile time. Nothing of it survives into the running game. Lattice has no dependency on it and never will — but the two fit together deliberately, and asChild accepting UI modifiers as siblings exists because of the shape Vela emits.

This page is about that seam: where the classes go, what each placement produces, and the two places the toolkits’ defaults disagree.

The three placements

One Switch primitive, three class-name placements, one rendered tree.

Every row above is Switch.Root + Switch.Thumb with no styling of its own — toggle them. Open the Classes tab for the source and Lowered for the props Vela produced.

On your own host element, inside asChild

The plainest case. asChild hands the part’s behavior to an element you render, and that element is an ordinary textbutton — so its className is lowered exactly as it would be anywhere else. All three rows paint their track from checked; the Wi-Fi row does it with a computed class value:

Classes on the element you own
<Switch.Root asChild checked={wifi} onCheckedChange={setWifi}>
<textbutton
className={`w-11 h-6 rounded-full ${wifi ? "bg-sky-500" : "bg-slate-700"}`}
LayoutOrder={2}
Text=""
>
<Switch.Thumb asChild>
<frame className="w-5 h-5 rounded-full bg-white" />
</Switch.Thumb>
</textbutton>
</Switch.Root>

Nothing about this is Lattice-specific. Reach for it when the element is already yours.

Note the LayoutOrder prop. A computed class value puts the element on Vela’s runtime path, where the layout families — order-* among them — are dropped, so the row’s UIListLayout would sort this button by instance name instead. Properties you set as props are untouched by any of that, which makes a prop the reliable place for anything a dynamically-classed element still needs from the layout.

On the part, with asChild

Put the classes on Switch.Root and Vela lowers them onto the component: resolved props become JSX attributes on Switch.Root, and helper instances are prepended as its children. That puts the UICorner next to the textbutton rather than inside it:

What Vela emits
<Switch.Root
asChild
checked={bluetooth}
onCheckedChange={setBluetooth}
BackgroundColor3={Color3.fromRGB(0, 166, 244)}
Size={UDim2.fromOffset(44, 24)}
>
<uicorner CornerRadius={new UDim(0.5, 0)} />
<textbutton Text="" />
</Switch.Root>

Two GuiObject-ish children where asChild clones exactly one — which is precisely the case Lattice 0.7.0 added, and 0.8.0 is the release where it works in Roblox. Slot re-parents the modifier under the element the props land on instead of treating it as a second slot candidate, so this renders the same tree as the first form. On 0.7.x the rule shipped keyed by the JSX tag rather than the Roblox class name @rbxts/react actually labels the element with, so every modifier missed it and the whole shape raised “expected exactly one child element besides any UI modifiers”. A browser React renderer keeps the tag as written and so needed the other spelling, which 0.8.1 added — the previews on this page ran into exactly that. See asChild composition for the full rule.

That emit is what the static path produces, and the Bluetooth row keeps it by branching the whole element between two literal class lists rather than computing one:

Two static branches, so both keep the static emit
{bluetooth ? (
<Switch.Root asChild checked className="w-11 h-6 rounded-full bg-sky-500" …>…</Switch.Root>
) : (
<Switch.Root asChild checked={false} className="w-11 h-6 rounded-full bg-slate-700" …>…</Switch.Root>
)}

It reads as duplication, and it buys something concrete: a computed value here — even a ternary between two string literals, which Vela does not fold — would lower to VelaRuntimeHost __velaTag={Switch.Root} instead, building the UICorner at runtime. The modifier never becomes a sibling, so the rule this section is about never comes up.

On the part, without asChild

Since 0.7.0 every part forwards unknown props onto the instance it renders, so asChild is no longer required to style one. The part renders its own textbutton, Vela’s props ride along, and the UICorner lands as a child:

No asChild — the part renders the host
<Switch.Root
checked={airplane}
className={`w-11 h-6 rounded-full opacity-100 ${airplane ? "bg-sky-500" : "bg-slate-700"}`}
LayoutOrder={2}
onCheckedChange={setAirplane}
>
<Switch.Thumb asChild>
<frame className="w-5 h-5 rounded-full bg-white" />
</Switch.Thumb>
</Switch.Root>

This row is computed, so it lowers to VelaRuntimeHost __velaTag={Switch.Root} — the runtime host renders the part itself, and the part still receives the resolved props, still forwards them, and still renders its children. A dynamic class value on a Lattice part works; it just costs you the static path’s full utility set, which is why LayoutOrder is a prop here too.

That opacity-100 is not decoration. It is the first of the two disagreements.

Where the defaults collide

Both toolkits neutralize Roblox’s opaque-gray GuiObject default, and they do it in different places. Most of the time that is invisible. Twice it is not.

What to keep off the class list

Two Vela behaviors are worth knowing before you put them near a primitive.

Anything you cannot afford to get wrong silently. Primitive state (checked, highlighted, disabled) is exactly the thing you want to paint from, and a computed className is the natural way to do it — the Wi-Fi row above does. As of Vela 0.8.0 every utility family resolves on the runtime path that a computed value puts the element on, so this is no longer about losing classes. It is about losing checking: nothing validates a class string the compiler never sees, so a typo in a computed value is silent where the same typo in a literal is a diagnostic.

Two version notes if you are not current. On 0.7.x and earlier the layout half of a computed class value — direction, alignment, order-*, position, text styling, constraints — was dropped outright with no diagnostic. And on 0.5.1 and earlier a computed class value left one sizing axis at zero, so w-11 h-6 on a track collapsed it and the track’s rounded-full disappeared with it. Fixed in 0.5.2 — the preview above only works because of it.

Geometry the primitive owns. Progress fill ratios, slider thumb travel, popper-driven position, scroll thumb size, presence-driven Visible — those are computed by the primitive and written onto the instance. A class that sets the same property fights it, and Vela’s value is emitted last, so the class wins and the behavior breaks. Style the parts around the moving one: the track, not the thumb’s Position.

Which tool for what

sx / createRecipeVela className
Resolvesat runtime, per renderat compile time
Reads ThemeProvideryesno — build-time palette
Ships runtime codeyesno, on the static path
Variant systemscreateRecipebranch between literals
Painting from primitive statenaturalneeds static literal branches
Setup costnone — already a dependencya transformer, a config, a .d.ts

Neither replaces the other, and mixing them on one element is fine as long as each property is set by only one of them. If you already run Vela in your game, Lattice primitives will take your class names without complaint. If you do not, sx and recipes are not a lesser path — they are the ones with runtime theming.

See also