Velaguides

Dynamic class names

Computing `className` loses no utilities. What a genuinely unreadable string costs.

className is typed as ClassValue, so you can compute it: template strings, ternaries, arrays, values from props or state.

What changes is how the string is lowered. Collapsible to a fixed set of tokens, it takes the static path and lowers straight to props. Otherwise the element falls onto the runtime path, where the host does the same work in-game.

The two paths resolve the same utilities

Every utility family the reference documents resolves on both paths with the same semantics. That covers opacity modifiers, arbitrary values, and the families that only meet at the end.

One asymmetry favours the runtime path. A utility the host element cannot carry is dropped rather than written, because writing TextColor3 onto a Frame is a hard Roblox error. The static path warns and writes it anyway.

opacity-* is the one family where the paths hand off. The compiler leaves the whole class list to the host, which resolves it and hands the children one alpha. See opacity-*.

A branch lowers at compile time

An expression that writes its classes out is resolved by the compiler even though it cannot collapse to one class list: active ? "text-lg" : "text-sm" names every token it can ever apply.

Both sizes reach the instance
// `text-lg` has no runtime resolution at all. Written as a branch it lowers here.
<textlabel className={big ? "text-lg" : "text-sm"} />

Four consequences, all of them the point:

  • The full utility set applies inside a branch, not the runtime resolver’s narrower prefix list.
  • A bad utility in a branch reports a diagnostic instead of vanishing.
  • A variant inside a branch answers to both — a hover: in a branch applies as hover: and the branch’s test, combined as one condition.
  • Each test is evaluated exactly once, however many branches hang on it.

It reads ternaries, &&, the literal behind ||, arrays and object maps, and resolves a branch among the tokens around it. So ["w-40", tall && "h-10"] composes into one Size.

The element still renders through the runtime host, because something has to read the tests, but nothing is parsed in-game.

What the emit carries
<VelaRuntimeHost
__velaRules={[
{ condition: { kind: "test", index: 0, expected: true }, effects: { props: [{ name: "TextSize", value: "18" }] } },
{ condition: { kind: "test", index: 0, expected: false }, effects: { props: [{ name: "TextSize", value: "14" }] } },
]}
__velaTests={[big ? true : false]}
__velaTag={"textlabel"}
/>

What sends a branch back to the runtime resolver

Three things, and the whole class value goes — not just the offending branch:

  • A token no rule can carrym-*, divide-*, animate-*, transition*, the text transforms and opacity-*. The host reads these off its own props rather than off the resolution.
  • A value the source never names`size-${n}`, a bare variable, a call, a spread.
  • The left side of ||, which is the class value itself when truthy. The literal behind it is still resolved here.

Two ordering rules. Branches touching the same property apply in written order, so [a && "bg-red-500", b && "bg-blue-500"] paints blue when both hold. Branches touching different halves of one property are not merged: [a && "w-40", b && "h-10"] is two writes to Size and the later wins.

What the runtime path still costs

This is about a class value the compiler cannot read at all — cn(...), a template string, a prop passed through.

No diagnostics. The compiler never sees the final string. A typo like bg-blu-600 is an unknown-theme-key warning in a literal or a branch, and complete silence here. The token resolves to nothing, and the element renders without a background.

No editor support for the part nothing names. Completions, hover and swatches work from the literal text, including the literal text inside an expression the compiler cannot fold. A token an interpolation cuts into, as in `w-[${width}]`, is left alone. One that merely sits beside an interpolation is checked normally.

Theme values are re-parsed from text. The static path splices your theme’s roblox-ts expression into the output. A class value the host has to parse carries the theme as string data instead. Two Luau parsers re-read it, accepting exactly Color3.fromRGB(r, g, b) and new UDim(a, b) with numeric arguments. Anything else compiles fine and then silently degrades in game. See configuration.

Code size is not on this list. @rbxts/vela-runtime is one ModuleScript the whole place shares, so a runtime-path module carries an import and its config rather than a copy:

What a runtime-path module carries
import { createVelaRuntimeHost } from "@rbxts/vela-runtime";
const VelaRuntimeHost = createVelaRuntimeHost({ /* … */ });

Three degrees, not two

“Static or runtime” is the shape of the emit. What decides how much checking you get is a third axis — whether the compiler could read the tokens:

What you wroteEmitted asResolved byChecked
className="flex gap-2", or an array of literalsYour tag, plain propsThe compilerYes
A variant, a branch, a margin, a divide-*VelaRuntimeHost with __velaRulesThe compiler; the host only reads conditionsYes
A string the compiler cannot readVelaRuntimeHost with classNameThe host, in-gameNo

Only the third row has a cost worth weighing. Any variant prefix puts an element in the middle row, className="sm:w-full" included, and so do the structural utilities.

How to tell which row an element landed in

There is no diagnostic and no flag, so check the emitted roblox-ts. A first-row element keeps its tag and gains plain props plus helper children. Anything else renders as VelaRuntimeHost with __velaTag: __velaRules with __velaTests means the compiler resolved everything, while a surviving className prop means the string is parsed in-game.

Writing for the checked rows

Reach for tokens the compiler can read wherever losing the typo check would hurt, and compute freely elsewhere.

Branch the classes, not the element

A template string is not a branch the compiler can read. Write it as one:

Read, resolved and checked
<frame className={["flex flex-col items-center gap-2 p-4 rounded-lg", emphasized ? "bg-slate-700" : "bg-slate-800"]} />

Duplicating the whole element still produces the leanest emit, with each branch a complete literal on the static path and no host at all. But it buys only that, not checking. Spend it where an element is hot enough that the difference shows.

Or use a variant instead of computing

If the thing driving the class is something Vela can observe, a variant expresses it without any computation. That covers pointer, press, focus, viewport, input device and colour scheme:

No computed string needed
<textbutton className="bg-slate-800 hover:bg-slate-700 active:bg-slate-600 transition" Text="Play" />

Computing is fine for the rest

A computed className for state your app owns is a normal thing to write, and every token in it resolves:

Fine — resolves fully, just without compile-time checking
const tone = disabled ? "bg-slate-800 text-slate-500" : "bg-blue-600 text-white";
return <frame className={`flex items-center gap-2 p-2 rounded-md ${tone}`} />;

The interpolation is what costs the typo check, not the ternary. Hoisting the same ternary into the class value gets it back:

Same rendering, checked
return (
<frame
className={[
"flex items-center gap-2 p-2 rounded-md",
disabled ? "bg-slate-800 text-slate-500" : "bg-blue-600 text-white",
]}
/>
);

Components take the same rows

Everything here applies to className on your own components. <Panel className={classes} /> is lowered exactly like <frame className={classes} />, branch resolution included. The one visible difference is the tag. __velaTag carries a live reference rather than a string, as __velaTag={Panel}, and the host renders your component with the resolved props and helper children.

So the component has to forward what it does not consume, or none of it lands — with no diagnostic for getting it wrong. See How it works.

A component only emits the modifier-sibling shape on the static path. Go dynamic and the UICorner is built at runtime instead.

See also