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.
Lattice primitives know nothing about Vela — you install and configure it yourself. Everything here works with no Vela at all. You would just be writing the props by hand.
The three placements
import { React } from "@lattice-ui/react-runtime";import { Switch } from "@lattice-ui/react-switch";
export function LatticeSwitch() { const [wifi, setWifi] = React.useState(true); const [bluetooth, setBluetooth] = React.useState(true); const [airplane, setAirplane] = React.useState(true);
return ( <frame className="flex flex-col gap-3 w-80 h-40 p-4 rounded-lg bg-slate-900 border border-slate-700"> <frame className="flex flex-row items-center gap-4 w-72 h-8"> <textlabel className="w-44 h-5 text-sm text-slate-100 text-left" LayoutOrder={1} Text="Wi-Fi" /> <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> </frame>
<frame className="flex flex-row items-center gap-4 w-72 h-8"> <textlabel className="order-1 w-44 h-5 text-sm text-slate-100 text-left" Text="Bluetooth" /> {bluetooth ? ( <Switch.Root asChild checked className="order-2 w-11 h-6 rounded-full bg-sky-500" onCheckedChange={setBluetooth} > <textbutton Text=""> <Switch.Thumb asChild> <frame className="w-5 h-5 rounded-full bg-white" /> </Switch.Thumb> </textbutton> </Switch.Root> ) : ( <Switch.Root asChild checked={false} className="order-2 w-11 h-6 rounded-full bg-slate-700" onCheckedChange={setBluetooth} > <textbutton Text=""> <Switch.Thumb asChild> <frame className="w-5 h-5 rounded-full bg-white" /> </Switch.Thumb> </textbutton> </Switch.Root> )} </frame>
<frame className="flex flex-row items-center gap-4 w-72 h-8"> <textlabel className="order-1 w-44 h-5 text-sm text-slate-100 text-left" Text="Airplane mode" /> <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> </frame> </frame> );}import { createVelaRuntimeHost, __VelaBoundary } from "@rbxts/vela-runtime";import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime";const VelaRuntimeHost = createVelaRuntimeHost({ "preflight": true, "theme": { "colors": {}, "radius": {}, "spacing": {}, "fontFamily": {}, "screens": {}, "rem": { "base": 16.0, "min": 16.0, "max": 16.0, "baseResolution": { "x": 1920.0, "y": 1020.0 } } }, "plugins": { "utilities": {} }}) as unknown as VelaRuntimeHostComponent;import { React } from "@lattice-ui/react-runtime";import { Switch } from "@lattice-ui/react-switch";export function LatticeSwitch() { const [wifi, setWifi] = React.useState(true); const [bluetooth, setBluetooth] = React.useState(true); const [airplane, setAirplane] = React.useState(true); return <__VelaBoundary.Consume>{(<frame BackgroundColor3={Color3.fromRGB(15, 23, 43)} Size={UDim2.fromOffset(320, 160)} BorderSizePixel={0}><uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, 12)} SortOrder={Enum.SortOrder.LayoutOrder}/><uipadding PaddingTop={new UDim(0, 16)} PaddingRight={new UDim(0, 16)} PaddingBottom={new UDim(0, 16)} PaddingLeft={new UDim(0, 16)}/><uicorner CornerRadius={new UDim(0, 8)}/><uistroke Thickness={1} Color={Color3.fromRGB(49, 65, 88)} Transparency={0}/> <frame Size={UDim2.fromOffset(288, 32)} BorderSizePixel={0} BackgroundTransparency={1}><uilistlayout FillDirection={Enum.FillDirection.Horizontal} VerticalAlignment={Enum.VerticalAlignment.Center} Padding={new UDim(0, 16)} SortOrder={Enum.SortOrder.LayoutOrder}/> <textlabel LayoutOrder={1} Text="Wi-Fi" TextSize={14} TextColor3={Color3.fromRGB(241, 245, 249)} TextXAlignment={Enum.TextXAlignment.Left} Size={UDim2.fromOffset(176, 20)} BorderSizePixel={0} BackgroundTransparency={1}/> <Switch.Root asChild checked={wifi} onCheckedChange={setWifi}> <VelaRuntimeHost LayoutOrder={2} Text="" className={`w-11 h-6 rounded-full ${wifi ? "bg-sky-500" : "bg-slate-700"}`} BorderSizePixel={(0 as never)} BackgroundTransparency={(1 as never)} __velaTag={"textbutton"}> <Switch.Thumb asChild> <frame BackgroundColor3={Color3.fromRGB(255, 255, 255)} Size={UDim2.fromOffset(20, 20)} BorderSizePixel={0}><uicorner CornerRadius={new UDim(0.5, 0)}/></frame> </Switch.Thumb> </VelaRuntimeHost> </Switch.Root> </frame>
<frame Size={UDim2.fromOffset(288, 32)} BorderSizePixel={0} BackgroundTransparency={1}><uilistlayout FillDirection={Enum.FillDirection.Horizontal} VerticalAlignment={Enum.VerticalAlignment.Center} Padding={new UDim(0, 16)} SortOrder={Enum.SortOrder.LayoutOrder}/> <textlabel Text="Bluetooth" LayoutOrder={1} TextSize={14} TextColor3={Color3.fromRGB(241, 245, 249)} TextXAlignment={Enum.TextXAlignment.Left} Size={UDim2.fromOffset(176, 20)} BorderSizePixel={0} BackgroundTransparency={1}/> {bluetooth ? (<Switch.Root asChild checked onCheckedChange={setBluetooth} LayoutOrder={2} BackgroundColor3={Color3.fromRGB(0, 166, 244)} Size={UDim2.fromOffset(44, 24)}><uicorner CornerRadius={new UDim(0.5, 0)}/> <textbutton Text=""> <Switch.Thumb asChild> <frame BackgroundColor3={Color3.fromRGB(255, 255, 255)} Size={UDim2.fromOffset(20, 20)} BorderSizePixel={0}><uicorner CornerRadius={new UDim(0.5, 0)}/></frame> </Switch.Thumb> </textbutton> </Switch.Root>) : (<Switch.Root asChild checked={false} onCheckedChange={setBluetooth} LayoutOrder={2} BackgroundColor3={Color3.fromRGB(49, 65, 88)} Size={UDim2.fromOffset(44, 24)}><uicorner CornerRadius={new UDim(0.5, 0)}/> <textbutton Text=""> <Switch.Thumb asChild> <frame BackgroundColor3={Color3.fromRGB(255, 255, 255)} Size={UDim2.fromOffset(20, 20)} BorderSizePixel={0}><uicorner CornerRadius={new UDim(0.5, 0)}/></frame> </Switch.Thumb> </textbutton> </Switch.Root>)} </frame>
<frame Size={UDim2.fromOffset(288, 32)} BorderSizePixel={0} BackgroundTransparency={1}><uilistlayout FillDirection={Enum.FillDirection.Horizontal} VerticalAlignment={Enum.VerticalAlignment.Center} Padding={new UDim(0, 16)} SortOrder={Enum.SortOrder.LayoutOrder}/> <textlabel Text="Airplane mode" LayoutOrder={1} TextSize={14} TextColor3={Color3.fromRGB(241, 245, 249)} TextXAlignment={Enum.TextXAlignment.Left} Size={UDim2.fromOffset(176, 20)} BorderSizePixel={0} BackgroundTransparency={1}/> <VelaRuntimeHost checked={airplane} LayoutOrder={2} onCheckedChange={setAirplane} className={`w-11 h-6 rounded-full opacity-100 ${airplane ? "bg-sky-500" : "bg-slate-700"}`} __velaTag={Switch.Root}> <Switch.Thumb asChild> <frame BackgroundColor3={Color3.fromRGB(255, 255, 255)} Size={UDim2.fromOffset(20, 20)} BorderSizePixel={0}><uicorner CornerRadius={new UDim(0.5, 0)}/></frame> </Switch.Thumb> </VelaRuntimeHost> </frame> </frame>)}</__VelaBoundary.Consume>;}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:
<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:
<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:
{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:
<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.
A Lattice part neutralizes the instance it renders with BackgroundTransparency = 1. Vela’s bg-* sets BackgroundColor3 and nothing else, and its preflight does not apply to components.
So className="bg-sky-500" on a part without asChild produces a transparent element painted
an invisible blue. Add opacity-100, or move the classes onto your own element with asChild,
where the part applies no neutral defaults.
Neither toolkit reports it. The symptom is an element laid out correctly, sized correctly, and simply not there.
sx and createRecipe read the live Theme from ThemeProvider, so a light/dark toggle re-renders into new colours. Vela resolves bg-slate-800 to a literal Color3 at compile time.
If your UI switches themes at runtime, keep colours and typography on sx/recipes and let Vela carry the geometry — sizing, padding, gaps, radius, layout.
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 / createRecipe | Vela className | |
|---|---|---|
| Resolves | at runtime, per render | at compile time |
Reads ThemeProvider | yes | no — build-time palette |
| Ships runtime code | yes | no, on the static path |
| Variant systems | createRecipe | branch between literals |
| Painting from primitive state | natural | needs static literal branches |
| Setup cost | none — already a dependency | a 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
- asChild composition — the modifier-sibling rule Vela’s output relies on
- Styling with recipes — the runtime half
- Vela: how it works — what lowering onto a component actually emits
- Vela: scope and status — the honest map of its edges