Vela has no runtime you install. It is a host adapter that decides which files to touch, plus a native compiler that does the class-name work.
The two ways to run Vela differ at exactly two points: what hands the adapter a file, and where the resulting text goes. Everything between is one shared pipeline, which is why both tracks emit the same Luau.
The pipeline
Something hands the adapter a file. Under the transformer, roblox-ts resolves the
tsconfig.json plugin entry. It calls the exported factory with your Program and its own
typescript instance. The factory throws without that instance, so plain tsc cannot drive it.
Under the CLI, vela build walks --src itself and calls the same adapter per file, with no
Program and no plugin options.
The host adapter filters files. Five conditions, in order. A file that fails any is passed through untouched:
- The filename ends in
.tsx, case-insensitively..tsfiles are never transformed, so aclassNamebuilt in a.tshelper is invisible to Vela. .d.tsand.d.tsxare skipped.- Any path containing a
node_modulessegment is skipped. - The source text contains the literal substring
className. - The source text matches a coarse JSX-open-tag regex.
The last three are toggles (skipNodeModules, requireClassName, requireJsxSyntax), all on by
default, and there is no glob support.
The adapter finds your config. Vela walks up from each file’s directory looking for
vela.config.ts or vela.config.json. The .ts form wins when a directory has both. A TypeScript
config is stripped of its vela-rbxts imports, transpiled, and executed in a scope where
defineConfig and defaultConfig are injected. A TypeScript diagnostic throws, as does an export
that is neither a resolved config nor an input-shaped object. Finding nothing gets you the default
theme. See config discovery for the caching.
The native compiler parses and lowers. It walks the JSX tree and resolves each class token against the merged theme. Out comes new source text plus a diagnostic list.
The text goes back out, as text rather than an AST. Under the transformer it is parsed into a
fresh SourceFile and substituted in memory. Diagnostics are handed back through roblox-ts’s
addDiagnostic. On a roblox-ts that does not expose that hook they are dropped silently. Under
the CLI the text is written to the mirrored path. It is skipped when byte-identical, so rbxtsc -w
is not woken. The CLI prints the diagnostics itself.
What gets touched
An element is lowered if it is a supported host element or a component. Vela recognizes exactly eight host elements:
frame, scrollingframe, canvasgroup, textlabel, textbutton, textbox, imagelabel, imagebutton
A component is any tag starting with an uppercase letter, such as <Panel />, or any member
expression such as <Switch.Root />, at any depth. A namespaced tag like <svg:rect /> is never a
component.
Anything else is skipped with its className left untouched, reporting
classname-on-unsupported-host. That covers lowercase intrinsics Vela does not implement, like
screengui, and namespaced tags. The warning names only the local part, so <svg:rect /> is
reported as rect.
className on components
<Panel className="p-4 bg-slate-800" /> is lowered the way a host element is. On the static path
the resolved props become ordinary JSX attributes on the component, className is removed, and
helper instances are prepended as children. A self-closing component that needs helpers gains a
closing tag:
<Box className="bg-slate-700 rounded-md" /><Box BackgroundColor3={Color3.fromRGB(49, 65, 88)}> <uicorner CornerRadius={new UDim(0, 6)} /></Box>Vela hands the component props and children. It cannot make the component do anything with them. If Box does not spread its unrecognized props onto a Roblox host element and render its children, the styling is lost. There is no diagnostic for that, at compile time or in the editor.
A component library can be built to receive this shape. lattice-ui forwards unknown props from every part and re-parents Vela’s helper instances under the element the props land on. Styling Lattice with Vela is a worked example.
The eventual host element is unknown, so the per-element utility restrictions do not apply inside a
component’s className. The editor offers the full set there and never raises
unsupported-host-utility. Whether text-lg means anything depends on which host your component
renders, and nothing checks that.
The two lowering paths
Which one you get changes the shape of the output and whether anything runs at runtime.
Static lowering
The default. The compiler resolves every token to a concrete value, emits Roblox properties on the
element, prepends helper instances as children, and removes the className. The element keeps its
tag, and nothing from Vela survives into the running game. This path supports the full utility set.
It also emits two properties you did not ask for. A host element carrying a className starts from
BackgroundTransparency = 1 and BorderSizePixel = 0. No class list has to paint over the opaque
grey box Roblox gives every GuiObject. Anything that actually paints opts back out, and a
background painted by a variant reopens it at runtime. Components are never preflighted. See
preflight.
The runtime path
When Vela cannot finish at compile time it swaps the element’s tag for VelaRuntimeHost, imported
from @rbxts/vela-runtime and configured once at the top of the module body. Static props it did
resolve are emitted with an as never cast, since the host’s prop type does not describe them.
Conditional rules are serialized into __velaRules, their conditions into __velaTests, and the
original tag travels as __velaTag. Structural work rides along in further internal props:
__velaTransition, __velaAnimation, __velaText, __velaMargin and __velaDivide.
For a host element, __velaTag is the tag string. For a component it is a live reference,
__velaTag={Box}, so the host renders your component rather than an intrinsic.
At runtime the host normalizes theme strings into Color3 and UDim values. It reads the
environment, applies whichever rules match, tokenizes any dynamic className, strips the internal
props, and calls React.createElement with the original tag and the helper children. It reads
Workspace.CurrentCamera.ViewportSize for width, height, orientation and rem. It reads
UserInputService.TouchEnabled / MouseEnabled / GamepadEnabled for input mode, and
MouseEnter/MouseLeave on the element for hover:.
The host is a package, not a copy. One ModuleScript the whole place shares, over the target-neutral
@rbxts/vela-runtime-core. A transformed module carries an import and its config:
import { createVelaRuntimeHost } from "@rbxts/vela-runtime";const VelaRuntimeHost = createVelaRuntimeHost({ preflight: true, theme: { … }, plugins: { … } });A Vide project imports @rbxts/vela-runtime-vide in the same position.
That config carries only what your project changed. The runtime holds the defaults itself, so an untouched scale sends {}. A file whose host never has to parse a class value sends its scales emptied entirely. One that does, through a computed className or a host taking a spread, keeps the full tables. preflight, theme.rem and the motion driver stay either way, and an emptied table is marked in theme.replaced so the runtime takes it as given.
What triggers the runtime path
Any one of these is sufficient.
A className expression that does not collapse to one static token list. A template literal
with an interpolation, a variable, a call, a spread. A branch is on this list only because it
needs the host to read its conditions. a ? "x" : "y" names its tokens in the source. The compiler
resolves every one of them, with diagnostics, and hands the element the resolved props alongside the
tests.
Any token carrying a variant prefix. A variant depends on the live environment, so even a plain static string literal forces the runtime path:
<frame className="w-full sm:w-1/2" />The trigger is the variant, not the dynamism. The cost is an import.
A structural utility. Margins (except the static mx-auto/my-auto), divide-* and
animate-* build wrapper frames, separator frames or animation loops. None can be a static prop.
The one motion utility that does not promote is transition-* alone. With nothing to animate, it
warns transition-without-runtime and is dropped.
It handles the colour, background, radius, padding, margin, gap and sizing prefixes, plus divide-*, the case transforms, the decorations and the motion families. Every other utility inside a text className the host has to parse is dropped with no diagnostic. That covers layout, alignment, text size, position and constraints.
This limit applies to text and nothing else. Variant-prefixed tokens and the tokens inside a branch
go through the static resolver and are only evaluated at runtime, which is why
big ? "text-lg" : "text-sm" works while `text-${size}` silently does not.
The full list of divergences is in Dynamic class names.
Five rules that keep you on the static path
Almost every “my class did nothing” report comes from an element that quietly took the runtime path.
- Write
classNameas a plain string literal. A literal inside a.map()callback is still a literal — what matters is the expression in the attribute. - When the look changes between known states, write the branch out, as
[base, on ? "bg-blue-600" : "bg-slate-700"], rather than interpolating a computed string. That keeps both looks resolved and checked. Branching the whole JSX element additionally avoids the host. - When a number changes continuously, set the property directly, as
Size={UDim2.fromScale(health, 1)}, and let the classes carry what does not change. Never set the same property from both a class and a prop. On a collision, Vela emits after you and the class wins. - Give every element an explicit size. Roblox starts instances at zero, so a missing
h-*is an invisible element, not a small one. - Prefer
gap-*to margins. Margins build a wrapper frame, which forces the runtime path even in a plain literal — and they sum with the parent’s gap.
Variants are the deliberate exception: hover: on a control, a breakpoint on a handful of top-level
containers. What does not earn the host is an opaque string written where a readable one would do.
The runtime resolver lowers everything the static path does, so what you lose is not the classes but
every check on them.
Next step
Get the honest boundaries of the project in Scope and status.