Vela reads a single optional file, vela.config.ts, and it configures exactly one thing: the theme.
There is no build configuration to write — file selection, output, and diagnostics all come from
tsconfig.json instead.
Discovery
The filename is exactly vela.config.ts. There are no .js, .mjs, .cjs, or .mts variants, and
no package.json key.
For each source file it is about to transform, Vela walks upward from that file’s directory to the
filesystem root and uses the first vela.config.ts it finds. If it finds none, it uses
defaultConfig. Because the walk starts at the source file, a config in a subdirectory shadows one
at the repo root for the files beneath it — which is usually accidental rather than intended.
There is no caching. Config resolution runs once per eligible source file, which means
vela.config.ts is re-read, re-transpiled, and re-executed for each .tsx file in your project.
Keep the file cheap: no filesystem access, no network, no heavy computation at module scope.
How the file is loaded
Vela does not hand the file to roblox-ts. It loads it itself, in three steps:
- Every
import ... from "vela-rbxts"statement is stripped from the source text. - The remainder is transpiled with
ts.transpileModuleto CommonJS with diagnostics reporting on. - The result is executed as a function with
exports,require,module,__filename,__dirname, and — this is the part that matters —defineConfiganddefaultConfiginjected as arguments.
The injection is why step 1 is safe: the import is removed, but the bindings it declared are still in scope when the code runs.
Three consequences follow from this. First, the file is transpiled, not type-checked. transpileModule
reports syntax and emit diagnostics only, and any of those throws Failed to compile vela.config.ts —
but a type error is not among them and passes silently. Second, because the transpiled output is then
executed through createRequire and new Function, module specifiers resolve at runtime relative to
the config file. Your project’s tsconfig.json paths aliases and ambient types are never consulted,
so the config cannot rely on either. Third, the export must be usable: Vela takes module.exports.default if present and
otherwise module.exports, then requires it to be either an already-resolved TailwindConfig or an
input-shaped object it can pass through defineConfig. Anything else throws
Expected <path> to export a TailwindConfig-compatible object.
import { defineConfig } from "vela-rbxts";
export default defineConfig({ theme: { extend: { colors: { brand: { 500: "Color3.fromRGB(99, 102, 241)", 700: "Color3.fromRGB(67, 56, 202)", }, }, }, },});Schema
The entire schema is one optional theme key. There is no content, plugins, presets,
darkMode, prefix, important, corePlugins, safelist, or variants option. Those keys do not
exist in the type and are ignored if you write them anyway.
| Prop | Type | Description |
|---|---|---|
| theme.colors | Record<string, string | Partial<Record<Shade | "DEFAULT", string>>> | Replaces the entire color registry. A value is either a literal roblox-ts expression string or a map from shade (50…950, plus the optional DEFAULT that a bare family name resolves to) to one. |
| theme.radius | Record<string, string> | Replaces the entire radius scale. Each value is a roblox-ts expression producing a UDim. |
| theme.spacing | Record<string, string> | Replaces the entire spacing scale. Each value is a roblox-ts expression producing a UDim. |
| theme.extend.colors | Record<string, string | Partial<Record<Shade | "DEFAULT", string>>> | Merges over the default color registry, per family and — when both sides are palettes — per shade. Setting DEFAULT on a family is what makes a bare bg-brand resolve. |
| theme.extend.radius | Record<string, string> | Shallow-merges over the default radius scale by key. |
| theme.extend.spacing | Record<string, string> | Shallow-merges over the default spacing scale by key. |
The valid shades are 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950, plus
one non-numeric key: DEFAULT. A palette that defines DEFAULT is what a bare family name resolves
to, so bg-brand needs no shade. It is a config key only — bg-brand-DEFAULT is not a valid class,
because only the eleven numeric tokens are treated as shades, so that payload is read as the semantic
key brand-DEFAULT and reported as unknown-theme-key. Every built-in palette ships a DEFAULT
mirroring its 500.
Bare palette names landed after 0.2.0 and are unreleased at the time of writing. On
vela-rbxts@0.2.0 from npm, a palette reference always needs an explicit shade, and a bare bg-brand
reports color-missing-shade.
An empty palette object throws
Color palette normalization requires at least one shade value. — but only where the palette is
actually normalized: a family that is not already in the registry, or any family under a top-level
theme.colors. Extending a family that already exists as a palette takes the merge path instead,
which spreads your object over the base and returns the base unchanged. So
theme.extend.colors: { blue: {} } throws nothing, warns nothing, and does nothing.
Values are roblox-ts expression strings
Every value in the theme is a string containing a roblox-ts expression. On the static path Vela parses that string and splices the resulting expression into the TSX it emits, where roblox-ts compiles it like any other expression in your source. It is not a color object, not a hex string, and not a number.
export default defineConfig({ theme: { extend: { colors: { ink: "Color3.fromRGB(17, 17, 17)" }, radius: { pill: "new UDim(0.5, 0)" }, spacing: { gutter: "new UDim(0, 20)" }, }, },});"#111111" and 4 are not valid values, and they fail at different times. 4 is not a string, and
every theme value must be one, so it is rejected while the config is being loaded. "#111111" is a
string, and nothing checks that a string is a valid roblox-ts expression. When the string does not
parse as an expression, Vela falls back to emitting it as a string literal, so the output is
BackgroundColor3={"#111111"} — a roblox-ts type error on the next build, rather than a config error.
The splice above is the static path only. A class resolved at runtime goes through the runtime
host, and there the whole resolved config is serialized to JSON and shipped as string data, then
re-parsed in Luau by two hand-written parsers that accept exactly two textual shapes:
Color3.fromRGB(r, g, b) with three numeric arguments in 0–255, and new UDim(a, b) with two
numeric arguments.
A value that is valid roblox-ts but not in one of those shapes — "Color3.new(0.1, 0.2, 0.3)",
"Color3.fromHex(...)", "new UDim(0, PADDING)" — compiles fine and then silently degrades
at runtime. Unparseable colors are dropped from the runtime color scale entirely; unparseable radius
and spacing values fall back to new UDim(0, 0). No diagnostic is reported, and the static path keeps
working, so the failure only shows up on dynamically classed elements. Keep every theme value in those
two literal forms with numeric arguments.
Merge semantics
For radius and spacing: a top-level key replaces the whole scale; extend shallow-merges over
the defaults by key. For colors: extend merges per family, and per shade when both the default
and your value are palettes. A literal replaces a palette wholesale, and vice versa.
If theme.colors is present, theme.extend.colors is not applied at all — it is dropped
without warning, and every built-in palette is dropped with it. This diverges from real Tailwind,
where extend is layered on top of a replaced colors.
So a config with theme.colors.surface and theme.extend.colors.brand resolves to a theme
containing only surface. brand-500 will not resolve, and neither will slate-700.
Add colors with theme.extend.colors alone. Only use top-level theme.colors when you genuinely
want to throw the default palette away. The same replace-wholesale rule applies to theme.radius and
theme.spacing.
The default theme
defaultConfig is the resolved form of the built-in defaults, and it is what you get when no
vela.config.ts is found.
Colors
Twenty-eight families. Two are literals: black and white. The other twenty-six are palettes with
all eleven shades (50 through 950), each also carrying a DEFAULT that mirrors that palette’s
500:
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, taupe.
The first twenty-two track the Tailwind palette. The last four — mauve, olive, mist, and
taupe — are Vela additions with no Tailwind equivalent. They are muted neutrals intended for
surfaces, and they behave exactly like any other palette: bg-mauve-800, text-taupe-300.
Radius
Ten keys.
| 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) |
Because rounded-* is a pure theme lookup with no numeric fallback, this table is the complete set
of radii you can write until you extend it.
Spacing
One key: "4", mapped to new UDim(0, 16).
There is no built-in spacing scale. Every other spacing value you write — p-2, gap-6, w-40 —
resolves through the compiler’s arithmetic fallback, not through the theme. That fallback accepts any
unsigned finite number that is a multiple of 0.5 and produces new UDim(0, key * 4), so p-2 is
8 pixels and gap-6 is 24 pixels.
The practical effect is that a lookup only fails for values that break the arithmetic rule — a
negative number, a fraction finer than 0.5, or a non-numeric word. Those give unknown-theme-key
in the spacing family. Adding named spacing keys through theme.extend.spacing is how you get
p-gutter-style tokens.
tsconfig plugin options
The plugin entry object in tsconfig.json is passed straight through as options. These are the keys
that are expressible in JSON.
| Prop | Type | Description |
|---|---|---|
| filter.skipNodeModules | boolean | Default true. Skip any file whose path contains a node_modules segment. |
| filter.requireClassName | boolean | Default true. Skip any file whose source text does not contain the substring className. |
| filter.requireJsxSyntax | boolean | Default true. Skip any file whose source text does not match an opening-JSX-tag pattern. |
| diagnosticCodeBase | number | Default 89000. The first numeric diagnostic code; each diagnostic in a file gets base + index. |
| projectRoot | string | Defaults to the program's current directory. Currently inert — nothing resolves paths from it; discovery walks up from each file being compiled. |
| config | TailwindConfig | An explicit resolved config used for every file. It overrides the discovered config but does not skip discovery, which still runs and can still fail the build. |
{ "compilerOptions": { "plugins": [ { "transform": "vela-rbxts/transformer", "filter": { "skipNodeModules": true }, "diagnosticCodeBase": 89000 } ] }}File eligibility
A file is transformed only if it passes all five checks, applied in this order:
- The filename ends with
.tsx, case-insensitively. A.tsfile is never transformed, whatever is in it. - It is not a declaration file — no
.d.tsor.d.tsx. - Its path contains no
node_modulessegment, unlessfilter.skipNodeModulesis off. - Its source text contains the literal substring
className, unlessfilter.requireClassNameis off. - Its source text matches an opening-JSX-tag pattern, unless
filter.requireJsxSyntaxis off.
There is no include or exclude glob support. The three booleans above are the only controls, so the
way to keep Vela out of a directory is to keep className out of the .tsx files in it.
Checks 4 and 5 are text scans, not semantic ones. A file that mentions className only inside a
comment still passes, which costs a compiler round trip but changes nothing in the output.
See also
- Theming for a working walkthrough of extending the theme.
- Utility reference for the classes that read these theme keys.
- API for
defineConfiganddefaultConfig.