Velareference

Configuration

How vela.config.ts is found and loaded, the complete theme schema, the built-in defaults, and the tsconfig plugin options.

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:

vela.config.json
{
"$schema": "./node_modules/vela-rbxts/schema.json",
"theme": {
"extend": {
"colors": { "brand": { "500": "Color3.fromRGB(99, 102, 241)" } }
}
}
}

How the TypeScript form is loaded

Vela does not hand the file to roblox-ts. It loads it itself, in three steps:

  1. Every import ... from "vela-rbxts" statement is stripped from the source text.
  2. The remainder is transpiled with ts.transpileModule to CommonJS with diagnostics reporting on.
  3. The result is executed as a function with exports, require, module, __filename and __dirname. defineConfig and defaultConfig are 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.

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

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.

PropTypeDescription
presetsreadonly (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.
preflightbooleanDefault 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.colorsRecord<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.radiusRecord<string, string>Replaces the entire radius scale. Each value is a roblox-ts expression producing a UDim.
theme.spacingRecord<string, string>Replaces the entire spacing scale. Each value is a roblox-ts expression producing a UDim.
theme.fontFamilyRecord<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.screensRecord<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".
pluginsVelaPlugin[] | { 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.colorsRecord<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.radiusRecord<string, string>Shallow-merges over the default radius scale by key.
theme.extend.spacingRecord<string, string>Shallow-merges over the default spacing scale by key.
theme.extend.fontFamilyRecord<string, string>Shallow-merges over the default font family scale by key.
theme.extend.screensRecord<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.

vela.config.ts
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.

vela.config.ts
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.

vela.config.ts
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.

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.

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.

KeyValue
DEFAULTnew UDim(0, 4)
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)

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.

KeyValue
sansrbxasset://fonts/families/SourceSansPro.json
serifrbxasset://fonts/families/Merriweather.json
monorbxasset://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:

vela.config.ts
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.

KeyDefault
sm640
md768
lg1024
xl1280
2xl1536

Every key gives two prefixes, a minimum width and its max- complement, so one entry is all a custom breakpoint takes:

vela.config.ts
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.

KeyDefaultWhat it is
base16Pixels per rem at baseResolution
min8Lower clamp, in pixels
max64Upper 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
vela.config.ts
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.

vela.config.ts
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.

FormExampleMeaning
Class listbtn: "bg-blue-600 rounded-lg px-4"Expands to those utilities
Property mappanel: { 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:btn builds a hover rule out of everything btn expands to.
  • It resolves on both paths. A btn inside a dynamic className works.
  • It can reach through another. "btn-lg": "btn text-lg" expands btn in 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.

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:

vela.config.ts
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-*.

vela.config.ts
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:

@acme/springs
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 resolve
from 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:

vela.config.json
{
"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.

vela.config.ts
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:

@acme/game-ui/vela.ts
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.

PropTypeDescription
filter.skipNodeModulesbooleanDefault true. Skip any file whose path contains a node_modules segment.
filter.requireClassNamebooleanDefault true. Skip any file whose source text does not contain the substring className.
filter.requireJsxSyntaxbooleanDefault true. Skip any file whose source text does not match an opening-JSX-tag pattern.
diagnosticCodeBasenumberDefault 89000. The first numeric diagnostic code; each diagnostic in a file gets base + index.
projectRootstringDefaults to the program's current directory. Currently inert — nothing resolves paths from it; discovery walks up from each file being compiled.
configTailwindConfigAn 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.
tsconfig.json
{
"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:

  1. The filename ends with .tsx, case-insensitively. A .ts file is never transformed, whatever is in it.
  2. It is not a declaration file — no .d.ts or .d.tsx.
  3. Its path contains no node_modules segment, unless filter.skipNodeModules is off.
  4. Its source text contains the literal substring className, unless filter.requireClassName is off.
  5. Its source text matches an opening-JSX-tag pattern, unless filter.requireJsxSyntax is 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 defineConfig and defaultConfig.