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.
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.
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 }, }, },});Everything above describes the static path, where your string becomes a real roblox-ts expression. A class that has to be resolved at runtime — anything reaching the runtime host through a dynamic className — takes a different route: the whole resolved theme is serialized to JSON and shipped as string data, then re-parsed in Luau by two small hand-written parsers that accept exactly two textual forms.
Color3.fromRGB(r, g, b)— the prefix must be literally that, with three numeric arguments in0–255.new UDim(a, b)— two numeric arguments.
A value that is perfectly good roblox-ts but not in one of those two shapes compiles fine and then silently degrades at runtime. "Color3.new(0.1, 0.2, 0.3)" and "Color3.fromHex(...)" are dropped from the runtime color scale outright, so the class writes nothing. "new UDim(0, PADDING)" — a constant instead of a literal number — falls back to new UDim(0, 0), so the radius or spacing collapses to zero. There is no diagnostic for any of this, and the static path keeps working, so the damage is invisible until you look at a dynamically classed element in-game.
Keep every theme value in those two literal forms with numeric arguments, even if you do not think you use a dynamic className.
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.
When a top-level theme.colors is present, theme.extend.colors is thrown away without a warning. The resolved theme contains only what you put in theme.colors.
This is not how real Tailwind behaves — there, extend is applied on top of a replaced colors. In Vela it is not. The same trap applies to radius and spacing: a top-level entry replaces the whole scale and its extend counterpart never runs.
Use theme.extend.colors alone to add colors. If you set both, every built-in palette disappears and the colors you thought you were adding under extend never resolve, so classes like bg-brand-500 fail with unknown-theme-key.
// 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.
This behavior landed after 0.2.0 and is unreleased at the time of writing. On vela-rbxts@0.2.0 from npm, every palette reference still needs an explicit shade and bg-slate reports color-missing-shade.
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-*.
| Key | Value |
|---|---|
none | new UDim(0, 0) |
xs | new UDim(0, 2) |
sm | new UDim(0, 4) |
md | new UDim(0, 6) |
lg | new UDim(0, 8) |
xl | new UDim(0, 12) |
2xl | new UDim(0, 16) |
3xl | new UDim(0, 24) |
4xl | new UDim(0, 32) |
full | new 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.
Completions offer a familiar-looking scale — 0, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 12, 16, 20, 24, 32, 40, 64, 80. That list is hardcoded editor sugar to make autocomplete useful. It is not theme data, and any other multiple of 0.5 works just as well.
See also
- Configuration reference — the full schema, config discovery, and load behavior.
- Colors and surfaces — the utilities that consume
theme.colorsandtheme.radius. - Layout and sizing — the utilities that consume
theme.spacing.