Vela reads one optional config file, covering the theme, whether preflight neutralizes
the Roblox host defaults, and which UI framework the emit targets. There is no build
configuration to write — file selection, output and diagnostics all come from tsconfig.json.
Discovery
Two filenames are recognized, exactly: vela.config.ts and vela.config.json. There are no .js,
.mjs, .cjs, or .mts variants, and no package.json key. When a directory contains both, the
.ts file wins.
Vela walks upward from each source file’s directory and uses the first match, falling back to
defaultConfig. A config in a subdirectory therefore shadows the repo root’s for the files beneath
it — usually accidental rather than intended.
The JSON form holds the same input shape as defineConfig’s argument, plus an optional $schema
key that is stripped before parsing. The package ships a schema.json you can point it at for
editor validation:
{ "$schema": "./node_modules/vela-rbxts/schema.json", "theme": { "extend": { "colors": { "brand": { "500": "Color3.fromRGB(99, 102, 241)" } } } }}The walk up from each source file runs per file, which is what lets two directories carry different configs. The load is resolved once per directory per build and re-read only when the file changes. A config that throws is cached too, so a typo is reported once and the next edit lifts it. Keep the file cheap regardless: it is executed as real code.
How the TypeScript form 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,__filenameand__dirname.defineConfiganddefaultConfigare injected as arguments too, which is the part that matters.
The injection is why step 1 is safe: the import goes, its bindings stay in scope.
Three consequences. The file is transpiled, not type-checked, so a type error passes silently.
Module specifiers resolve relative to the config file, never through tsconfig.json paths.
And the export must be usable: a resolved TailwindConfig or an input-shaped object. Anything
else throws, naming the key that failed.
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 schema is five optional keys: preflight, theme, plugins since 0.7.0, framework since
0.12.0, and presets since 0.13.0. There is no content, 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 |
|---|---|---|
| presets | readonly (TailwindConfigInput | TailwindConfig)[] | Shareable slices of configuration folded in before this config resolves. Added in 0.13.0. They resolve after the built-in defaults and in array order, so the config that names them always outranks what it pulled in. definePreset() types one without resolving it. A vela.config.json can inline a preset object but cannot import one from a package. |
| framework | "react" | "vide" | Which UI library the project's JSX compiles for. Default react. Added in 0.12.0. Left unset it is inferred from the nearest tsconfig.json: a compilerOptions.jsxFactory beginning with Vide. selects Vide. Writing the key at all — with either value — stops that inference. |
| preflight | boolean | Default true. Neutralizes the Roblox host defaults on every supported host element that carries a className. Set false to restore the pre-0.5.0 behavior, where Vela only ever added properties. |
| 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.fontFamily | Record<string, string> | Replaces the entire font family scale. Each value is a Roblox font family asset path — a rbxasset:// font JSON or an uploaded rbxassetid://. Added in 0.7.0; the keys are what font-{family} looks up. |
| theme.screens | Record<string, number> | Replaces the breakpoint scale that the responsive variants are named after, in pixels of viewport width. Added in 0.13.0. Defaults: sm 640, md 768, lg 1024, xl 1280, 2xl 1536. Every key gives both a min-width prefix and its max- complement, so one entry named tablet defines tablet: and max-tablet:. |
| theme.rem | { base?: number; min?: number; max?: number; baseResolution?: { x: number; y: number }; pinnedUnder?: string[] } | How one rem resolves against the viewport, which is what every pixel offset a utility lowers is measured in. Added in 0.12.0. Defaults: base 16, min 8, max 64, baseResolution 1920×1020, pinnedUnder ["surfacegui", "billboardgui"]. A record rather than a keyed scale, so it merges field by field and theme.extend.rem behaves identically — except pinnedUnder, the one list among them, which replaces, since a list that merged could never say "none". |
| plugins | VelaPlugin[] | { utilities, motion } | Plugins that register utility classes of their own and can replace the motion driver. Added in 0.7.0. In vela.config.json, state the resolved object form directly. |
| 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. |
| theme.extend.fontFamily | Record<string, string> | Shallow-merges over the default font family scale by key. |
| theme.extend.screens | Record<string, number> | Shallow-merges over the default breakpoint scale by key, so a new name joins sm…2xl instead of replacing them. |
| theme.extend.rem | { base?: number; min?: number; max?: number; baseResolution?: { x: number; y: number } } | The same field-by-field merge theme.rem does. rem is the one family where the replace-versus-extend distinction does not exist, because it is a record of four settings rather than a scale of keys. |
The valid shades are 50 through 950, plus one non-numeric key: DEFAULT, which a bare family
name resolves to — so bg-brand needs no shade. It is a config key only: bg-brand-DEFAULT is read
as the semantic key brand-DEFAULT and reported as unknown-theme-key. Every built-in palette
ships a DEFAULT mirroring its 500.
An empty palette object throws Color palette normalization requires at least one shade value., but
only where the palette is normalized. That means a family not already in the registry, or any family
under a top-level theme.colors. Extending an existing palette takes the merge path instead, so
theme.extend.colors: { blue: {} } throws nothing and does nothing.
framework
Which UI library the project’s JSX compiles for. "react" is the default, and "vide" emits for
Vide. Most projects never write it: left unset, the target is
inferred from tsconfig.json.
export default defineConfig({ framework: "vide" });It decides two things: the module specifier the emit imports for its runtime host, and the reactive shape of what is handed to that host. Everything else is target-neutral, and a statically lowered element is byte-identical under both.
Inference walks up to the nearest tsconfig.json, follows a relative extends up to eight
levels, and reads compilerOptions.jsxFactory. A factory beginning with Vide. selects Vide. It is
keyed on whether the config names the key, so framework: "react" pins React even under a Vide
jsxFactory.
The Vide guide covers what changes when you write the code.
preflight
Roblox paints every GuiObject as an opaque grey box with a 1px border. A supported host element
carrying a className starts from BackgroundTransparency = 1 and BorderSizePixel = 0 instead,
so a class list says everything about how the element looks.
<frame className="w-20 h-10" />// → <frame Size={UDim2.fromOffset(80, 40)} BorderSizePixel={0} BackgroundTransparency={1} />
<frame className="w-20 h-10 bg-slate-800" />// → <frame BackgroundColor3={Color3.fromRGB(29, 41, 61)} Size={UDim2.fromOffset(80, 40)} BorderSizePixel={0} />The transparency is only added when nothing else paints the element. A bg-*, opacity-*, a
gradient stop or a transparency prop of your own all opt back out, and BorderSizePixel is skipped
when you declared it. A background painted by a variant reopens the element at runtime. Never
touched: an element with no className, and a component.
export default defineConfig({ preflight: false });Turning it off means Vela only ever adds the properties your classes name, and the gray default shows through wherever you do not paint over it.
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 it and splices the expression into the TSX it emits. It is not a colour 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 both fail, at different times. 4 is rejected at load. "#111111" is a string
nothing validates, so it falls back to a string literal. That is a roblox-ts type error on the
next build rather than a config error. The
theming guide has the three shapes
side by side.
The splice above is the static path only. A class resolved at runtime carries your theme as serialized text. Two Luau parsers re-read it, accepting exactly Color3.fromRGB(r, g, b) and new UDim(a, b) with numeric arguments. Anything else compiles fine and then silently degrades at runtime, with no diagnostic, on dynamically classed elements only.
Merge semantics
For radius, spacing and fontFamily, a top-level key replaces the whole scale and 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.
A config with theme.colors.surface and theme.extend.colors.brand resolves to a theme containing
only surface — neither brand-500 nor slate-700 resolves.
Add colours with theme.extend.colors alone. Every axis behaves this way — a top-level theme.radius, theme.spacing or theme.fontFamily discards both the defaults and its own extend counterpart in the same silence.
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. mauve, olive, mist and taupe are Vela
additions.
Radius
Ten named keys, plus a DEFAULT that bare rounded resolves to.
| Key | Value |
|---|---|
DEFAULT | new UDim(0, 4) |
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) |
rounded-* is a pure lookup with no numeric fallback, so this is the complete set until you extend
it.
Spacing
One key: "4", mapped to new UDim(0, 16).
There is no built-in spacing scale. p-2, gap-6 and w-40 come from the compiler’s arithmetic
fallback: any unsigned finite multiple of 0.5, giving new UDim(0, key * 4). A negative number, a
finer fraction or a non-numeric word reports unknown-theme-key. Use theme.extend.spacing for
named p-gutter-style tokens.
fontFamily
Three keys. Each value is a Roblox font family asset path, not an expression.
| Key | Value |
|---|---|
sans | rbxasset://fonts/families/SourceSansPro.json |
serif | rbxasset://fonts/families/Merriweather.json |
mono | rbxasset://fonts/families/RobotoMono.json |
sans is what an element gets when no font-{family} is present. Any Roblox font family works,
including one you uploaded:
export default defineConfig({ theme: { extend: { fontFamily: { display: "rbxassetid://12345678", body: "rbxasset://fonts/families/Nunito.json", }, }, },});That gives font-display and font-body. font-* resolves the fixed weight names first and reads
anything else as a key here. A payload that is neither reports unknown-theme-key. Unlike the other
axes, these are not roblox-ts expressions: they go into Font’s first argument as written.
screens
The viewport widths, in pixels, the responsive variants are named after.
| Key | Default |
|---|---|
sm | 640 |
md | 768 |
lg | 1024 |
xl | 1280 |
2xl | 1536 |
Every key gives two prefixes, a minimum width and its max- complement, so one entry is all a
custom breakpoint takes:
export default defineConfig({ theme: { extend: { screens: { tablet: 900 } } },});That defines both tablet: (≥ 900) and max-tablet: (< 900). theme.screens replaces the scale
outright, removing sm through 2xl, so any class still naming one reports unknown-breakpoint.
theme.extend.screens merges by key instead.
rem
These decide what one rem is worth, and every pixel offset a utility lowers is measured in rem — see the theming guide.
| Key | Default | What it is |
|---|---|---|
base | 16 | Pixels per rem at baseResolution |
min | 8 | Lower clamp, in pixels |
max | 64 | Upper clamp, in pixels |
baseResolution | { x: 1920, y: 1020 } | The viewport base is calibrated against |
pinnedUnder | ["surfacegui", "billboardgui"] | Containers whose subtree keeps literal pixels. Since 0.12.5 |
export default defineConfig({ theme: { rem: { base: 16, min: 8, max: 64, baseResolution: { x: 1920, y: 1020 } }, },});rem is a record of settings rather than a scale of keys, so the replace-versus-extend rule does
not apply. theme.rem and theme.extend.rem do the same thing, merging field by field.
pinnedUnder is the exception: it replaces, and emptying it puts both containers back on the curve.
A SurfaceGui or BillboardGui takes its pixel space from the part it is drawn on rather than the
viewport, so following the curve there is wrong. The container element in the JSX opens a pin, and
what is written under it lowers to literal offsets. A container the compiler never sees is outside
this. Pin such a project with rem: { min: 16, max: 16 }. An inverted clamp collapses onto min
during resolution.
Plugins
A plugin is a function that receives an API object and registers things on it. The plugin() helper
from @vela-rbxts/config wraps one, with an optional name.
import { defineConfig, plugin } from "@vela-rbxts/config";
export default defineConfig({ plugins: [ plugin(({ addUtilities, theme }) => { addUtilities({ btn: "bg-blue-600 rounded-lg px-4 py-2", panel: { BorderSizePixel: "0", BackgroundColor3: theme("colors.zinc.950") }, }); }, { name: "acme" }), ],});Plugin functions run while the config resolves, not while a file compiles, so the compiler, the runtime host and the LSP all receive the same plain table.
addUtilities
A registered utility is either a utility class list or a Roblox property map.
| Form | Example | Meaning |
|---|---|---|
| Class list | btn: "bg-blue-600 rounded-lg px-4" | Expands to those utilities |
| Property map | panel: { BorderSizePixel: "0" } | Assigns those properties directly |
Property-map values follow the same rule as theme values: they are roblox-ts expression strings, not numbers or objects.
Four things are true of a registered utility:
- It takes variants.
hover:btnbuilds a hover rule out of everythingbtnexpands to. - It resolves on both paths. A
btninside a dynamicclassNameworks. - It can reach through another.
"btn-lg": "btn text-lg"expandsbtnin turn. - It sorts ahead of the plain utilities, so a
bg-*written beside one still wins:className="btn bg-rose-600"is rose, regardless of token order.
The depth cap that stops { a: "b", b: "a" } recursing terminates the expansion rather than reporting it. The element comes out carrying none of the utilities the cycle named. If a plugin utility does nothing at all, check whether it reaches back into itself.
addVariant
The states a UI has of its own, such as a panel being open or a row selected, have no fixed list for Vela to guess at. A plugin registers one against a Roblox attribute on the styled instance:
plugin(({ addVariant }) => { addVariant("open", { attribute: "State", equals: "open" }); addVariant("premium", { attribute: "Tier", equals: 3 });});That gives an open: prefix reading instance:GetAttribute("State"). equals takes a string,
number or boolean, and attr-[State=open]: reads one inline. Both forms compose with every other
variant and are checked, completed and sorted like a built-in one. Only the condition travels to the
runtime. An attr-[…] that does not parse reports malformed-attribute-variant.
theme()
theme("colors.blue.600") reads the resolved theme, defaults merged and extend applied, and
returns the roblox-ts expression string for that key. A second argument is used when the path is
missing.
Motion driver
setMotionDriver replaces TweenService as what executes
transition and animate-*.
plugin(({ setMotionDriver }) => { setMotionDriver({ module: "@acme/springs", export: "driver" });});The runtime host imports that module and calls its transition and animate methods.
Each method is taken over on its own, so a driver that implements only transition keeps the
built-in animate-* presets on TweenService.
They have to be methods, not properties holding arrows:
export const driver = { transition(instance: Instance, goal: object, spec: { time: number }) { … }, animate(instance: Instance, preset: string) { … },};roblox-ts compiles a method with an implicit self and an arrow without one, so the two shapes are
not interchangeable across the call. The runtime calls them as methods and types them as methods, so
the arrow form is a compile error.
A driver is also handed the helper instances. Tweens on UICorner, UIStroke and UIShadow
arrive with a fourth argument naming which helper is moving. It is additive, so a three-argument
driver keeps working. The driver is imported by every transformed module that needs one, so the
specifier must be a package name or a path relative to your baseUrl. A relative ./ specifier is
rejected at load:
plugins.motion set the motion driver module to "./driver". A relative path cannot resolvefrom every module that imports the driver; use a package name or a baseUrl-relative path.Omit export to import the module’s default export.
The JSON form
vela.config.json cannot hold functions, so it states the resolved shape that plugins produce:
{ "plugins": { "utilities": { "btn": "bg-blue-600 rounded-lg px-4 py-2", "panel": { "BorderSizePixel": "0" } }, "motion": { "module": "@acme/springs" } }}It is the same object the TypeScript form produces once its plugin functions have run, so you give
up theme() and any logic, not any capability.
Presets
A preset is a shareable slice of configuration: a design system’s theme, plugins, utilities and variants, folded into a project in one line.
import { defineConfig } from "vela-rbxts";import { gameUiPreset } from "@acme/game-ui/vela";
export default defineConfig({ presets: [gameUiPreset()], theme: { extend: { colors: { brand: { 500: "Color3.fromRGB(99, 102, 241)" } } } },});Presets resolve after the built-in defaults and before the config naming them, in written order.
It is a fold over configuration inputs rather than a merge of finished configs, so a preset
replacing theme.radius stays extendable by the project’s theme.extend.radius. definePreset()
types a preset without resolving it:
import { definePreset } from "@vela-rbxts/config";
export const gameUiPreset = () => definePreset({ theme: { extend: { screens: { tablet: 900 } } }, });A vela.config.json can inline a preset object but cannot import one from a package.
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 glob support — the three booleans above are the only controls. Checks 4 and 5 are text
scans, so a file mentioning className only in a comment still passes.
See also
- Theming for a working walkthrough of extending the theme.
- Utility reference for the classes that read these theme keys.
- API for
defineConfiganddefaultConfig.