Vela guides

Theming

How the Vela theme works — three families of roblox-ts expression strings, the extend-versus-replace merge rules, and what ships by default.

A Vela theme has exactly three families: colors, radius, and spacing. There is nothing else. No content, no plugins, no presets, no darkMode, no prefix, no safelist, no variants — those Tailwind config keys do not exist here and are ignored if you write them.

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)" },
},
},
});

Values are source code, not values

The single most surprising thing about the config: every theme value is an expression string written in the roblox-ts dialect. It is not a color object, not a hex string, not a number. On the static path Vela parses the string you write and splices the resulting expression into the TSX it emits, where roblox-ts compiles it like any other expression in your source, so it has to be an expression that evaluates in Roblox.

That means colors are written as "Color3.fromRGB(59, 130, 246)" and radius and spacing values as "new UDim(0, 6)" — roblox-ts syntax, not the UDim.new(0, 6) you would write in Luau. Writing "#3b82f6" or 6 does not work, but the two fail very differently. A number is rejected at config-load time — every theme value must be a string, so radius: { pill: 999 } throws while the config is being read. A string is taken at face value: nothing checks that it is a valid roblox-ts expression. When the string does not parse as one, Vela falls back to emitting it as a string literal, so the output is BackgroundColor3={"#3b82f6"} — not pasted garbage, but a roblox-ts type error that surfaces on the next build rather than as a config error.

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 },
},
},
});

The config file itself is discovered by walking up from the source file being compiled, looking for exactly one filename: vela.config.ts. See the configuration reference for discovery, loading, and the full schema.

Extend merges, top-level replaces

Each of the three families can be set in two places, and the two places mean different things.

theme.extend.X merges over the built-in defaults. Your keys win on collision, everything else you did not mention survives.

theme.X at the top level replaces the entire scale. If you write theme.radius, the ten built-in radius keys are gone and only yours exist. If you write theme.spacing, the one built-in spacing key is gone. If you write theme.colors, all 28 built-in color families are gone.

For colors the merge is deeper than a key swap: extend merges family by family, and when both sides of a family are shade palettes it merges shade by shade. So extending blue with only a 400 key keeps the other ten shades. A literal color replaces a palette wholesale, and a palette replaces a literal wholesale — there is no blending across those two shapes.

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 genuinely want to start from an empty scale — a locked-down design system where referencing bg-slate-700 should be an error.

Semantic colors and shade palettes

A color 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 are 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, and 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 — the same convention Tailwind uses for nested color objects. With DEFAULT set, bg-brand works with no shade at all. Every built-in palette ships one, mirroring its 500, so bg-slate, text-blue, border-rose, and from-sky all resolve without any config.

DEFAULT is a config key, not a class name. bg-slate-DEFAULT is not a valid class: only the eleven numeric tokens count as shades, so that payload 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)",
},
},
},
}

Which one a class name means is decided by split_color_key. It splits the class payload at the last hyphen and treats the trailing part as a shade only if that part is exactly one of the eleven shade tokens. Otherwise the whole payload is a single semantic key.

So bg-my-color looks up the semantic key my-color. It is not read as the family my with the shade color. Multi-word semantic names are safe, and the only names you cannot use are ones that 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 is color-invalid-shade, asking a palette for a shade it does not define is also color-invalid-shade, and a name that is not in the theme at all is unknown-theme-key.

What ships by default

Colors

The defaults are 26 shade palettes at 11 shades each — plus a DEFAULT on every one of them, mirroring that palette’s 500 — and the two literals black (Color3.fromRGB(0, 0, 0)) and white (Color3.fromRGB(255, 255, 255)).

The palettes are slate, gray, zinc, neutral, stone, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose, mauve, olive, mist, and taupe.

The last four — mauve, olive, mist, and taupe — are not Tailwind palettes. They are Vela additions and they behave exactly like the rest: 11 shades, referenced as bg-mauve-300, text-taupe-800, and so on.

Radius

Ten keys, consumed by rounded-*.

KeyValue
nonenew UDim(0, 0)
xsnew UDim(0, 2)
smnew UDim(0, 4)
mdnew UDim(0, 6)
lgnew UDim(0, 8)
xlnew UDim(0, 12)
2xlnew UDim(0, 16)
3xlnew UDim(0, 24)
4xlnew UDim(0, 32)
fullnew UDim(0.5, 0)

Spacing

theme.spacing ships exactly one key: "4", mapped to "new UDim(0, 16)". That is the entire built-in spacing theme.

Everything else you have ever written — p-2, gap-6, px-1.5, w-40 — does not come from the theme at all. When a spacing key is not found in theme.spacing, the compiler falls back to arithmetic: the key is multiplied by 4 and emitted as new UDim(0, key * 4).

The fallback has rules. The key must not start with - or +, must parse as a finite number that is zero or greater, and must be a multiple of 0.5. So p-1.5 is 6px and p-40 is 160px, while p-0.25 and p-[-2] are rejected with unknown-theme-key on the spacing family.

Two consequences worth internalising. First, you almost never need to add spacing keys — the arithmetic covers the whole numeric range. Add a key only when you want a named step like gutter, or a value the arithmetic cannot express. Second, because "4" is a real theme entry, redefining it in theme.extend.spacing changes what p-4 means while leaving p-3 and p-5 on the arithmetic path. That asymmetry is easy to trip over.

See also