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.
// `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 ashover: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.
<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 carry —
m-*,divide-*,animate-*,transition*, the text transforms andopacity-*. 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:
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 wrote | Emitted as | Resolved by | Checked |
|---|---|---|---|
className="flex gap-2", or an array of literals | Your tag, plain props | The compiler | Yes |
A variant, a branch, a margin, a divide-* | VelaRuntimeHost with __velaRules | The compiler; the host only reads conditions | Yes |
| A string the compiler cannot read | VelaRuntimeHost with className | The host, in-game | No |
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.
className={["flex", "gap-2"]} collapses to one class list and stays in the first row — there is no
condition in it to read. A ternary does not collapse, but it lands in the middle row rather than
the last: both branches are resolved and checked at compile time.
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:
<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:
<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:
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:
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
- The five rules — this page compressed to what you have to remember while writing code.
- Responsive and input variants — the other trigger for the runtime path.
- How it works — the compile pipeline and where the two paths diverge.
- Vide — the same three rows under the other target.