Latticeguides

Styling with Vela

Style headless primitives with Tailwind-shaped class names, using vela-rbxts.

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

There is a third, and it is not ours: vela-rbxts turns Tailwind-shaped className strings into Roblox properties and helper instances at compile time. Lattice has no dependency on it, but 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 behaviour to an element you render. That element is an ordinary textbutton, so its className lowers as it would anywhere. All three rows paint their track from checked, and the Wi-Fi row uses 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 order-* is dropped and the row’s UIListLayout sorts by instance name. Props are untouched by that, which makes them the reliable place for what a dynamically-classed element 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 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. Slot re-parents the modifier under the element the props land on rather than treating it as a second candidate. This renders the same tree as the first form. See asChild composition.

That emit is what the static path produces. 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 buys something concrete: a computed value would lower to VelaRuntimeHost __velaTag={Switch.Root} instead, building the UICorner at runtime.

On the part, without asChild

Every part forwards unknown props onto the instance it renders, so asChild is not 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 host renders the part, which receives the resolved props, forwards them and renders its children. A dynamic class value on a Lattice part works — it costs the static path’s checking, 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. Every utility family resolves on the runtime path a computed value puts the element on. You do not lose classes, you lose checking. A typo in a computed value is silent, where the same typo in a literal is a diagnostic.

Geometry the primitive owns. Progress fill ratios, slider thumb travel, popper-driven position, scroll thumb size and presence-driven Visible are computed and written onto the instance. A class setting the same property wins, because Vela emits last, and the behaviour breaks. Style the parts around the moving one.

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. If you already run Vela, Lattice primitives take your class names. If not, sx and recipes are the ones with runtime theming.

See also