Velaguides

Theming

Expression strings rather than values, the extend-versus-replace rules, rem scaling, and the defaults.

A Vela theme has six axes: colors, radius, spacing, fontFamily, screens and rem. Nothing else exists in theme. Tailwind’s content, darkMode, prefix, safelist and variants are not keys here and are ignored. Two keys do exist beside theme rather than inside it: plugins and presets.

vela.config.ts
import { defineConfig } from "vela-rbxts";
export default defineConfig({
theme: {
extend: {
colors: {
brand: {
500: "Color3.fromRGB(99, 102, 241)",
700: "Color3.fromRGB(67, 56, 202)",
},
surface: "Color3.fromRGB(24, 24, 27)",
},
radius: { card: "new UDim(0, 10)" },
spacing: { gutter: "new UDim(0, 20)" },
},
},
});
bg-surface, rounded-card, p-gutter and bg-brand-500 — all four keys come from the config above.

That preview is compiled against that config plus a pinned rem, so a frame renders one pixel per pixel. It extends rather than replaces, so the built-in text-slate-100 still resolves beside the new keys. The Lowered tab shows each spliced in as the expression the config declared.

Values are source code, not values

Every theme value is an expression string written in the roblox-ts dialect. Not a colour object, not a hex string, not a number. Vela parses the string and splices the expression into the TSX it emits. Colours are "Color3.fromRGB(59, 130, 246)", and radius and spacing values are "new UDim(0, 6)". That is roblox-ts syntax, not Luau’s UDim.new(0, 6).

"#3b82f6" and 6 both fail, differently. A number is rejected at load. A string is never rejected: Vela splices in the first expression that parses, discards the rest, and emits no diagnostic.

vela.config.ts
export default defineConfig({
theme: {
extend: {
// correct — a roblox-ts expression, as a string
colors: { ink: "Color3.fromRGB(17, 24, 39)" },
radius: { pill: "new UDim(0.5, 0)" },
// wrong — accepted by the config, then fails when roblox-ts compiles it
// colors: { ink: "#111827" },
// wrong — not a string, so this throws at config-load time
// radius: { pill: 999 },
},
},
});

Only what you changed travels

The emit does not carry your whole palette. @rbxts/vela-runtime holds the default theme itself, and a transformed module sends only the entries that differ:

The config in an emitted file, for a project that changed nothing
theme = { colors = {}, radius = {}, spacing = {}, fontFamily = {}, rem = { … } }

An untouched family sends nothing. Overriding one shade sends that whole colour family, so the shades around it survive the merge. A top-level theme.colors replaces the scale, so it travels whole and names itself in theme.replaced, telling the runtime not to merge its defaults back underneath. None of this changes what a class resolves to.

Extend merges, top-level replaces

theme.extend.X merges over the built-in defaults. Your keys win on collision, everything else survives. theme.X at the top level replaces the entire scale, so writing theme.colors drops all 28 built-in families.

For colours the merge is deeper than a key swap. extend merges family by family, and shade by shade when both sides are palettes, so extending blue with only a 400 keeps the other ten shades. A literal replaces a palette wholesale, and vice versa.

vela.config.ts
// Do not do this — `brand` never resolves, and every built-in palette is dropped.
export default defineConfig({
theme: {
colors: { surface: "Color3.fromRGB(24, 24, 27)" },
extend: {
colors: { brand: { 500: "Color3.fromRGB(99, 102, 241)" } },
},
},
});

Reach for a top-level family only when you want to start from an empty scale. A locked-down design system, where referencing bg-slate-700 should be an error.

Rem

Every pixel offset a utility lowers is a rem unit rather than a raw pixel, so p-4, w-40, rounded-lg and text-sm all follow the viewport. One rem is 16px at 1920×1020 and scales from there. It is on by default, with no provider to mount and no hook to call.

vela.config.ts — the defaults, written out
export default defineConfig({
theme: {
rem: {
base: 16,
min: 8,
max: 64,
baseResolution: { x: 1920, y: 1020 },
},
},
});

The curve follows Littensy’s rem provider. It measures the viewport diagonal against baseResolution and caps the width at 19:9, so an ultrawide does not inflate the scale. It falls off more gently in portrait, then rounds and clamps into [min, max].

rem is the one axis where extend and replace are the same thing. It is a record of settings rather than a scale of keys, so both merge field by field.

What rem does not touch

Scale-valued utilities. w-full, h-1/2 and translate-x-1/2 stay fractions of the parent, and colours, alignments, LayoutOrder and ZIndex have no pixel in them. TextSize is scaled with a ceiling of 100, where Roblox stops honouring it.

Pinning offsets back to literal pixels

Close the clamp:

vela.config.ts
export default defineConfig({
theme: { rem: { min: 16, max: 16 } },
});

With no room left in it every viewport resolves the same rem, and the compiler drops the scaling from the emit entirely. Offsets lower to plain UDim2/UDim literals with no binding and no scaler, exactly as before rem existed:

className="p-4 w-40" with the clamp closed
<frame Size={UDim2.fromOffset(160, 0)}>
<uipadding PaddingTop={new UDim(0, 16)} />
</frame>

Pinning somewhere other than base is still a scale, so it keeps the binding. An inverted clamp collapses onto min during config resolution rather than erroring in-game.

A SurfaceGui is pinned already. It takes its pixel space from the part it is drawn on, and a BillboardGui sizes itself the same way. rem.pinnedUnder names those containers, ["surfacegui", "billboardgui"] by default, and what is written under one lowers to literal offsets. Emptying the list puts both back on the curve. A container the compiler never sees needs the closed clamp above.

What the emit looks like

A statically lowered element has no render to re-run when the viewport changes. It carries its offsets as bindings, and the file builds a scaler above it:

className="p-4 w-40" with rem active
import { createVelaRemScaler } from "@rbxts/vela-runtime";
const __VelaRem = createVelaRemScaler({ base: 16, min: 8, max: 64, baseResolution: { x: 1920, y: 1020 } });
<frame Size={__VelaRem.scale(UDim2.fromOffset(160, 0), 4)}>
<uipadding PaddingTop={__VelaRem.scale(new UDim(0, 16), 0)} />
</frame>

The number beside each value is a slot, so the file allocates one binding per scaled offset rather than one per read. TextSize goes through scaleText, where the 100 ceiling applies. An element that already needs the runtime host keeps plain values and is handed the names of the props to scale.

Semantic colors and shade palettes

A colour entry is either a literal, one roblox-ts expression string used as-is, or a palette, an object keyed by shade. The eleven valid shade tokens run from 50 to 950. A palette does not have to define all of them, but any shade you reference must be present.

A palette may also carry a twelfth key, DEFAULT, which is what a bare family name resolves to, so bg-brand works with no shade. Every built-in palette ships one mirroring its 500, so bg-slate, text-blue and from-sky all resolve without any config.

DEFAULT is a config key, not a class name: bg-slate-DEFAULT is read as the semantic key slate-DEFAULT and reported as unknown-theme-key. Completions offer the bare family name instead.

vela.config.ts
theme: {
extend: {
colors: {
// literal — used as `bg-surface`, with no shade
surface: "Color3.fromRGB(24, 24, 27)",
// palette — used as `bg-brand-500`, or bare `bg-brand` for the DEFAULT
brand: {
DEFAULT: "Color3.fromRGB(99, 102, 241)",
500: "Color3.fromRGB(99, 102, 241)",
700: "Color3.fromRGB(67, 56, 202)",
},
},
},
}

split_color_key decides which one a class name means. It splits the payload at the last hyphen and treats the trailing part as a shade only if it is exactly one of the eleven tokens. Otherwise the whole payload is one semantic key.

So bg-my-color looks up the semantic key my-color, not the family my with the shade color. Multi-word names are safe. The only ones you cannot use end in a hyphen plus a shade number.

The failure modes are distinct diagnostics. Referencing a palette that defines no DEFAULT without a shade is color-missing-shade. Giving a literal a shade, or asking a palette for a shade it does not define, is color-invalid-shade. A name that is not in the theme at all is unknown-theme-key.

What ships by default

Twenty-six colour palettes at eleven shades each, every one carrying a DEFAULT that mirrors its 500, plus the literals black and white. Ten radius keys and a DEFAULT. One spacing key, "4". Three font families, five breakpoints and the rem curve.

The configuration reference lists every key and value. Two things about those defaults are worth knowing here.

The spacing scale is arithmetic, not data. A key not found in theme.spacing is multiplied by 4. p-1.5 is 6px and p-40 is 160px, without either being in the theme. Add spacing keys only for a named step like gutter. Because "4" is a real entry, redefining it changes p-4 while leaving p-3 and p-5 on the arithmetic path.

screens and fontFamily are the two exceptions to this page’s rule. Their values are plain numbers and asset paths, not expression strings. font-* looks up family keys after the fixed weight names, so avoid naming one after a weight — a fontFamily.bold would never resolve.

See also