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:
{ "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:
<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:
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.
A thunk is about tracking, not lowering. () => (active() ? "bg-blue-600" : "bg-slate-700") names both tokens in the source. Both are resolved by the compiler, and the element is handed the resolved props alongside the test, exactly as the React form is. Only the test itself is thunked.
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.
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" /> );}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"}/>);}import React, { useState } from "@rbxts/react";
export function Button() { const [active] = useState(false);
return ( <textbutton className={active ? "bg-blue-600" : "bg-slate-700"} Text="Ready" /> );}import { createVelaRuntimeHost } from "@rbxts/vela-runtime";import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime";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 React, { useState } from "@rbxts/react";export function Button() { const [active] = useState(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"}/>);}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:
<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.
import Vide from "@rbxts/vide";
export function Panel(p: { big: () => boolean }) { return <frame className={() => (p.big() ? "m-4 p-2" : "p-2")} />;}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"}/>;}import React from "@rbxts/react";
export function Panel(props: { big: boolean }) { return <frame className={props.big ? "m-4 p-2" : "p-2"} />;}import { createVelaRuntimeHost } from "@rbxts/vela-runtime";import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime";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 React from "@rbxts/react";export function Panel(props: { big: boolean;}) { return <VelaRuntimeHost className={props.big ? "m-4 p-2" : "p-2"} BorderSizePixel={(0 as never)} BackgroundTransparency={(1 as never)} __velaTag={"frame"}/>;}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:
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.
jsxFactory is a program-wide TypeScript setting, so a project pointing it at Vide cannot compile React
JSX at all. The choice is per project by construction. It is also why an unset framework is read as the default rather than as a positive request for React. A project that never mentioned Vela’s config but compiles Vide JSX is asking for Vide.
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:
import "vela-rbxts";See also
- Dynamic class names — what a branch costs and what it does not, on both targets.
framework— the config key itself.- Release notes — what shipped in the Vide pass.