Velagetting started

Your first component

Build a small panel with Vela utilities and read the Roblox properties and helper instances it lowers to.

The fastest way to understand Vela is to write a small component and read what comes out. This page builds a panel with a title and a caption, then walks through what each class name became.

The panel this page builds, compiled by Vela and rendered in the browser.

That preview is not a screenshot. The example was lowered by the same compiler rbxtsc loads. Classes is the source with className on it, Lowered is what came out, and that source is the snippet below, line for line.

The Lowered tab also shows BackgroundTransparency and BorderSizePixel on elements whose classes mention neither. That is preflight. Roblox paints every GuiObject as an opaque grey box, so an element carrying a className starts transparent and borderless unless something paints it.

The component

src/client/Panel.tsx
import React from "@rbxts/react";
export function Panel() {
return (
<frame className="flex flex-col gap-4 p-4 rounded-lg bg-slate-800 border border-slate-700 w-80 h-24">
<textlabel
className="w-full h-6 text-left text-slate-100 text-lg font-semibold"
Text="Loadout"
/>
<textlabel
className="w-full h-5 text-left text-slate-400 text-sm"
Text="Two slots remaining"
/>
</frame>
);
}

Every class here resolves at compile time. The one thing that survives into the output is the rem scaler, which makes those offsets follow the player’s viewport. What comes out shows it.

Put it on screen

Vela changes nothing about how you mount a component. This is ordinary @rbxts/react-roblox. The one Vela-specific detail is the wrapper: screengui is not one of the eight host elements Vela lowers, so classes belong on a frame inside it.

src/client/main.client.tsx
import React from "@rbxts/react";
import { createPortal, createRoot } from "@rbxts/react-roblox";
import { Players } from "@rbxts/services";
import { Panel } from "./Panel";
const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui");
const root = createRoot(new Instance("Folder"));
root.render(
createPortal(
<screengui ResetOnSpawn={false} ZIndexBehavior={Enum.ZIndexBehavior.Sibling}>
<Panel />
</screengui>,
playerGui,
),
);

Rojo maps this file into StarterPlayer/StarterPlayerScripts like any other client entry point. The .client in the filename is what makes roblox-ts emit a LocalScript. Note the extension too: Vela only transforms .tsx files.

Build, sync, and press play: the panel from the preview above is now in your place.

What each class lowers to

The frame classes split two ways. Some set properties on the Frame. The rest become helper instances, because Roblox models layout, padding, corners and strokes as separate objects.

ClassResultValue
flexUIListLayout child, FillDirectionEnum.FillDirection.Horizontal
flex-colUIListLayout.FillDirectionEnum.FillDirection.Vertical
gap-4UIListLayout.Paddingnew UDim(0, 16)
p-4UIPadding child, all four sidesnew UDim(0, 16)
rounded-lgUICorner.CornerRadiusnew UDim(0, 8)
bg-slate-800BackgroundColor3Color3.fromRGB(29, 41, 61)
borderUIStroke.Thickness1
border-slate-700UIStroke.Color and TransparencyColor3.fromRGB(49, 65, 88), 0
w-80 h-24SizeUDim2.fromOffset(320, 96)

Every offset in that column is a rem value at the base viewport, not a fixed pixel count. new UDim(0, 16) is what p-4 is worth at 1920×1020. See rem.

A few of those deserve a note.

flex and flex-col write to the same instance. Bare flex sets FillDirection to Horizontal, and flex-col overwrites it with Vertical. Only one UIListLayout is emitted however many layout classes you stack, so flex-col alone is enough for a column.

gap-4 and p-4 both resolve 4 through the theme’s spacing scale, where the built-in default has its only entry. Every other numeric key comes from an arithmetic fallback: a non-negative multiple of 0.5, times four pixels. That is why w-80 is 320 pixels rather than a lookup failure.

w-80 and h-24 merge into one Size rather than one replacing the other, producing a single UDim2.fromOffset(320, 96). Drop the h-24 and the height falls back to zero, so the panel disappears. On the runtime path the merge does not happen and the later token wins.

border and border-slate-700 are two utilities sharing a prefix. The bare form sets thickness. The colour form sets colour and transparency, so border-slate-700 alone gives a coloured stroke with no explicit thickness.

The textlabel classes are mostly text-only. text-slate-100 sets TextColor3, text-lg sets TextSize to 18, text-left sets TextXAlignment to Left, and font-semibold sets FontFace. Their w-full h-6 goes through the same sizing path as the panel’s, mixing a scale and an offset into new UDim2(1, 0, 0, 24).

text-* is disambiguated by value, not prefix. A known size key becomes TextSize, an alignment keyword becomes TextXAlignment, and anything else is read as a colour. Misspell a size and you get a theme-key warning about a colour.

What comes out

The transformer removes className, emits the resolved properties on the element, and prepends the helper instances ahead of your own children. Here is the emitted TSX, with line breaks added:

Transformed output (line breaks added; nothing else changed)
import { createVelaRemScaler, __VelaOpacity } from "@rbxts/vela-runtime";
const __VelaRem = createVelaRemScaler({
base: 16, min: 8, max: 64, baseResolution: { x: 1920, y: 1020 },
});
<frame
BackgroundColor3={Color3.fromRGB(29, 41, 61)}
Size={__VelaRem.scale(UDim2.fromOffset(320, 96), 11)}
BorderSizePixel={0}
>
<uilistlayout
FillDirection={Enum.FillDirection.Vertical}
Padding={__VelaRem.scale(new UDim(0, 16), 4)}
SortOrder={Enum.SortOrder.LayoutOrder}
/>
<uipadding
PaddingTop={__VelaRem.scale(new UDim(0, 16), 5)}
PaddingRight={__VelaRem.scale(new UDim(0, 16), 6)}
PaddingBottom={__VelaRem.scale(new UDim(0, 16), 7)}
PaddingLeft={__VelaRem.scale(new UDim(0, 16), 8)}
/>
<uicorner CornerRadius={__VelaRem.scale(new UDim(0, 8), 9)} />
<uistroke Thickness={__VelaRem.scale(1, 10)} Color={Color3.fromRGB(49, 65, 88)} Transparency={0} />
<textlabel
Text="Loadout"
TextXAlignment={Enum.TextXAlignment.Left}
TextColor3={Color3.fromRGB(241, 245, 249)}
TextSize={__VelaRem.scaleText(18, 0)}
Size={__VelaRem.scale(new UDim2(1, 0, 0, 24), 1)}
FontFace={new Font("rbxasset://fonts/families/SourceSansPro.json", Enum.FontWeight.SemiBold)}
BorderSizePixel={0}
BackgroundTransparency={1}
/>
<textlabel
Text="Two slots remaining"
TextXAlignment={Enum.TextXAlignment.Left}
TextColor3={Color3.fromRGB(144, 161, 185)}
TextSize={__VelaRem.scaleText(14, 2)}
Size={__VelaRem.scale(new UDim2(1, 0, 0, 20), 3)}
BorderSizePixel={0}
BackgroundTransparency={1}
/>
</frame>

Four things there were never in your class list. BorderSizePixel and the labels’ BackgroundTransparency are preflight. The panel skips the transparency because bg-slate-800 paints it. SortOrder = LayoutOrder is emitted on every Vela layout so order-* works.

Every offset is wrapped in __VelaRem.scale. That is rem. The literal inside is the value at a 1920×1020 viewport, and the trailing number is a binding slot. TextSize goes through scaleText, which caps at 100. Pin theme.rem and the wrappers disappear, leaving plain literals.

That is why the Lowered tab shows plain literals and this block does not. Every preview in these docs is compiled with rem pinned, to keep the render one pixel per pixel.

One more thing is not shown above. A component definition’s root is wrapped in <__VelaOpacity.Fade>, which is how an opacity-* on an ancestor reaches a statically lowered subtree. It renders no instance.

The helpers are prepended, so they sort ahead of your own children. That does not affect layout, but it matters if you index children positionally.

This is the static path. The element keeps its tag, no class list is parsed in game, and every token was resolved and checked at build time. The pipeline page explains when that stops being true.

Something that does not work

Vela is a Roblox-shaped subset of Tailwind, not a port. The Roblox text engine exposes no letter spacing, so tracking-* cannot mean anything.

This warns
<frame className="tracking-wide p-4 bg-slate-800" />

The build still succeeds, but the transformer reports a warning through roblox-ts:

Compiler output
[@vela-rbxts/compiler] no-roblox-equivalent: Tailwind "tracking" utilities have no Roblox equivalent, so "tracking-wide" is ignored.

p-4 and bg-slate-800 are applied normally. Only the inexpressible token is dropped. A class Vela has never heard of gets unsupported-utility-family instead, so the two failure modes are distinguishable.

Colours have their own failure mode. You reach a shade by naming it or through the palette’s DEFAULT. A palette with neither is an error rather than a fallback. Say a brand family defines only 500 and 700:

This warns too
<frame className="bg-brand" />
Compiler output
[@vela-rbxts/compiler] color-missing-shade: Color palette "brand" for background color utility has no "DEFAULT" shade, so it requires an explicit shade such as "brand-500" in className literal.

Write bg-brand-500, or add a DEFAULT key. The built-in palettes all ship one mirroring their 500, which is why bg-slate needs no shade while bg-brand does.

The full list of codes, and what each one means, is in the diagnostics reference.

Next step

Read How it works for how the transformer fits into an rbxtsc build, and what makes it switch to a runtime path.