Velagetting started

How it works

The pipeline end to end, where the transformer and CLI diverge, and the two lowering paths.

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:

  1. The filename ends in .tsx, case-insensitively. .ts files are never transformed, so a className built in a .ts helper is invisible to Vela.
  2. .d.ts and .d.tsx are skipped.
  3. Any path containing a node_modules segment is skipped.
  4. The source text contains the literal substring className.
  5. 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:

In
<Box className="bg-slate-700 rounded-md" />
Out (shape, not verbatim)
<Box BackgroundColor3={Color3.fromRGB(49, 65, 88)}>
<uicorner CornerRadius={new UDim(0, 6)} />
</Box>

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:

What a runtime-path module emits
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.

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:

Static string, 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.

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.

  1. Write className as a plain string literal. A literal inside a .map() callback is still a literal — what matters is the expression in the attribute.
  2. 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.
  3. 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.
  4. Give every element an explicit size. Roblox starts instances at zero, so a missing h-* is an invisible element, not a small one.
  5. 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.