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.
Lattice primitives know nothing about Vela. You install and configure Vela in your own project — see its installation guide — and Lattice primitives simply turn out to be well-shaped targets for what it emits. Everything on this page also works with no Vela in the project 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, __VelaOpacity } from "@rbxts/vela-runtime";import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime";const VelaRuntimeHost = createVelaRuntimeHost({ "preflight": true, "theme": { "colors": {}, "radius": {}, "spacing": {}, "fontFamily": {}, "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 <__VelaOpacity.Fade>{(<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>)}</__VelaOpacity.Fade>;}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:
<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:
<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:
{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:
<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.
A Lattice part neutralizes the instance it renders with BackgroundTransparency = 1 — deliberately,
so a primitive never imposes a look. Vela’s bg-* sets BackgroundColor3 and nothing else, and
Vela’s own preflight does not apply to components, because
the host element a component will render is unknown to it.
So className="bg-sky-500" on a part without asChild produces a fully transparent element
painted an invisible blue. Add opacity-100 — which lowers to BackgroundTransparency = 0 — or move
the classes onto your own element with asChild, where the part applies no neutral defaults at all.
There is no diagnostic for this in either toolkit. The symptom is an element that is 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 colors. Vela resolves bg-slate-800 to a literal Color3 at compile time — there is
nothing left to re-read.
Vela’s theme lives in vela.config.ts and is a build-time palette, not a runtime context. If your UI
switches themes at runtime, keep colors and typography on sx/recipes and let Vela carry the
geometry — sizing, padding, gaps, radius, layout. That split works well and is the one we would
recommend by default; the mistake is expecting a class list to follow a ThemeProvider.
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 / 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 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
- 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