Velaguides

Vide

Emitting for Vide — no config to write, the same static output, and a thunk for anything dynamic.

Vela emits for Vide as well as React, and everything this documentation says about utilities, variants, the theme and the two lowering paths holds under either target.

A Vide project needs no Vela config for this. With framework unset, Vela reads the target off the jsxFactory a Vide project’s tsconfig.json already sets:

tsconfig.json — enough on its own
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "Vide.jsx",
"jsxFragmentFactory": "Vide.Fragment"
}
}

Both runtime hosts ship with vela-rbxts, and the one you do not emit for is an inert ModuleScript.

The config key exists for saying it out loud rather than having it read — see naming the target explicitly.

The static path is the same output

A statically lowered element is identical source under both targets. Vide’s JSX intrinsics are the same lowercase Roblox class names React’s are, and its props are Roblox instance properties directly:

Both targets emit this
<frame BackgroundColor3={Color3.fromRGB(49, 65, 88)} Size={__VelaRem.scale(UDim2.fromOffset(160, 0), 4)}>
<uipadding PaddingTop={__VelaRem.scale(new UDim(0, 16), 0)} />
</frame>

The whole token-to-utility pipeline is shared: the same parser, theme, diagnostics, helper children and rem scaling. Two things differ. The module specifier at the top of the file is @rbxts/vela-runtime-vide rather than @rbxts/vela-runtime, and the runtime path behaves differently.

A dynamic class value has to be a thunk

The one thing you write differently. A Vide component body runs once, so a class value that changes has to be a function, like any other derived Vide prop:

src/client/Button.tsx
const active = source(false);
// Tracked — the class list is re-read whenever `active` changes.
<textbutton className={() => (active() ? "bg-blue-600" : "bg-slate-700")} />
// Not tracked — read once, at the moment the component body ran.
<textbutton className={active() ? "bg-blue-600" : "bg-slate-700"} />

className is typed to allow both, as ClassValue | (() => ClassValue), so the second line is not an error. It is a class list that was correct once, and nothing warns about it.

The editor reads inside the thunk. Completions, hover, swatches, diagnostics and the class sort all work on a deferred class value the way they do on a literal one.

Both targets resolve the branch into the same two __velaRules. What differs is the one line inside __velaTests, and the runtime the host is imported from.

src/client/Button.tsx
import Vide, { source } from "@rbxts/vide";
export function Button() {
const active = source(false);
return (
<textbutton
className={() => (active() ? "bg-blue-600" : "bg-slate-700")}
Text="Ready"
/>
);
}
Emitted for Vide
import { createVelaRuntimeHost } from "@rbxts/vela-runtime-vide";
import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime-vide";
const VelaRuntimeHost = createVelaRuntimeHost({
"preflight": true,
"theme": {
"colors": {},
"radius": {},
"spacing": {},
"fontFamily": {},
"screens": {},
"rem": {
"base": 16.0,
"min": 8.0,
"max": 64.0,
"baseResolution": {
"x": 1920.0,
"y": 1020.0
}
},
"replaced": [
"colors",
"radius",
"spacing",
"fontFamily",
"screens"
]
},
"plugins": {
"utilities": {}
}
}) as unknown as VelaRuntimeHostComponent;
import Vide, { source } from "@rbxts/vide";
export function Button() {
const active = source(false);
return (<VelaRuntimeHost Text="Ready" BorderSizePixel={(0 as never)} BackgroundTransparency={(1 as never)} __velaRules={[
{
"condition": {
"kind": "test",
"index": 0,
"expected": true
},
"effects": {
"props": [
{
"name": "BackgroundColor3",
"value": "Color3.fromRGB(21, 93, 252)"
},
{
"name": "BackgroundTransparency",
"value": "0"
}
],
"helpers": []
}
},
{
"condition": {
"kind": "test",
"index": 0,
"expected": false
},
"effects": {
"props": [
{
"name": "BackgroundColor3",
"value": "Color3.fromRGB(49, 65, 88)"
},
{
"name": "BackgroundTransparency",
"value": "0"
}
],
"helpers": []
}
}
]} __velaTests={[
()=>active() ? true : false
]} __velaTag={"textbutton"}/>);
}
The same button under both targets, lowered by the compiler at build time. Switch the tab: the rules do not move.

What the runtime host does differently

Nothing you have to think about, on a host element. The Vide host binds an effect that writes whatever the current resolution names. A property that disappears goes back to what the element declared, or to the class default. Interaction variants, transition-* and animate-*, the text transforms, divide-* and the opacity-* model all behave as the React host’s do, on the same target-neutral core.

The reactive seams underneath are different, and two of their consequences reach you.

A component element’s props are fixed at the call

Vide hands a component its props once, rather than writing to an instance it owns. Which props the host can pass down is decided when the component is called:

A prop that only a later reading names cannot appear
<Card className={() => (loud() ? "bg-red-500 rounded-xl" : "bg-slate-700")} />

Both branches are read up front, so both BackgroundColor3 and the UICorner are accounted for. What cannot work is a name no reading at call time produced. A host element has no such limit — it is an instance the host owns.

A late m-* cannot be honoured

A margin is a wrapper instance above the element, and Vide parents an element as soon as it builds one, so the box cannot be introduced afterwards. A class value naming m-* anywhere the compiler can see it emits __velaMarginBox, and the host builds the box before the element.

src/client/Panel.tsx
import Vide from "@rbxts/vide";
export function Panel(p: { big: () => boolean }) {
return <frame className={() => (p.big() ? "m-4 p-2" : "p-2")} />;
}
Emitted for Vide
import { createVelaRuntimeHost } from "@rbxts/vela-runtime-vide";
import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime-vide";
const VelaRuntimeHost = createVelaRuntimeHost({
"preflight": true,
"theme": {
"colors": {},
"radius": {},
"spacing": {},
"fontFamily": {},
"screens": {},
"rem": {
"base": 16.0,
"min": 8.0,
"max": 64.0,
"baseResolution": {
"x": 1920.0,
"y": 1020.0
}
}
},
"plugins": {
"utilities": {}
}
}) as unknown as VelaRuntimeHostComponent;
import Vide from "@rbxts/vide";
export function Panel(p: {
big: () => boolean;
}) {
return <VelaRuntimeHost className={()=>p.big() ? "m-4 p-2" : "p-2"} BorderSizePixel={(0 as never)} BackgroundTransparency={(1 as never)} __velaMarginBox={true} __velaTag={"frame"}/>;
}
React gets no such prop — the same component, written the way each target writes it.

React emits nothing in that position, because the render that resolves a margin also renders the wrapper around it.

What is left is a margin arriving out of an opaque call, where the runtime warns rather than rendering the element unspaced.

Naming the target explicitly

The inference is a walk, and it is worth knowing how far it reaches before overriding it. From each source file’s directory upward to the nearest tsconfig.json, following a relative extends up to eight levels, reading compilerOptions.jsxFactory. A factory beginning with Vide. selects Vide. Anything else, or none found, leaves the default of React.

Naming the key in vela.config.ts always wins, and it wins by being present rather than by its value:

vela.config.ts — only if you want to override the walk
import { defineConfig } from "vela-rbxts";
export default defineConfig({ framework: "vide" });

So framework: "react" pins React even under a Vide jsxFactory. And framework: "vide" pins Vide where the walk would not have found the factory: a tsconfig reached through a non-relative extends, or one nested deeper than the walk goes. Both are escape hatches. A project whose tsconfig says Vide. in the ordinary way has no reason to write either.

Writing the declaration file

Unchanged. The same side-effect import augments both namespaces, React.Attributes and Vide.Attributes, so one file covers whichever you compile for:

src/vela-env.d.ts
import "vela-rbxts";

See also