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

Source: https://docs.astra-void.xyz/lattice-ui/guides/styling-with-vela/

[Styling with recipes](https://docs.astra-void.xyz/lattice-ui/guides/styling-with-recipes.md) 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**](https://docs.astra-void.xyz/vela-rbxts/index.md) 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](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) 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.

> **This is an integration, not a dependency**
>
> Lattice primitives know nothing about Vela. You install and configure Vela in your own project — see
> [its installation guide](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md) — 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

_Interactive preview: 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:

```tsx title="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](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md), 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:

```tsx title="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](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) 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:

```tsx title="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:

```tsx title="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.

> **1. bg-* alone will not paint a part that renders its own instance**
>
> 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](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#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.

> **2. Theme switching is a runtime idea; a class list is not**
>
> `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](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) 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](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) — the modifier-sibling rule Vela's
  output relies on
- [Styling with recipes](https://docs.astra-void.xyz/lattice-ui/guides/styling-with-recipes.md) — the runtime half
- [Vela: how it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#classname-on-components) — what
  lowering onto a component actually emits
- [Vela: scope and status](https://docs.astra-void.xyz/vela-rbxts/getting-started/scope-and-status.md) — the honest map of its edges
