A Vela theme has six axes: colors, radius, spacing, fontFamily, screens and rem. Nothing
else exists in theme. Tailwind’s content, darkMode, prefix, safelist and variants are
not keys here and are ignored. Two keys do exist beside theme rather than inside it:
plugins and
presets.
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)" }, }, },});import React from "@rbxts/react";
export function ThemedCard() { return ( <frame className="flex flex-col justify-center gap-3 w-72 h-28 p-gutter rounded-card bg-surface border border-brand-700"> <textlabel className="w-full h-5 text-left text-slate-100 text-sm font-semibold" Text="Season pass" /> <textbutton className="w-24 h-9 rounded-card bg-brand-500 text-white text-sm" Text="Claim" /> </frame> );}import { __VelaBoundary } from "@rbxts/vela-runtime";import React from "@rbxts/react";export function ThemedCard() { return <__VelaBoundary.Consume>{(<frame BackgroundColor3={Color3.fromRGB(24, 24, 27)} Size={UDim2.fromOffset(288, 112)} BorderSizePixel={0}><uilistlayout FillDirection={Enum.FillDirection.Vertical} HorizontalAlignment={Enum.HorizontalAlignment.Center} Padding={new UDim(0, 12)} SortOrder={Enum.SortOrder.LayoutOrder}/><uipadding PaddingTop={new UDim(0, 20)} PaddingRight={new UDim(0, 20)} PaddingBottom={new UDim(0, 20)} PaddingLeft={new UDim(0, 20)}/><uicorner CornerRadius={new UDim(0, 10)}/><uistroke Thickness={1} Color={Color3.fromRGB(67, 56, 202)} Transparency={0}/> <textlabel Text="Season pass" TextXAlignment={Enum.TextXAlignment.Left} TextColor3={Color3.fromRGB(241, 245, 249)} TextSize={14} Size={new UDim2(1, 0, 0, 20)} FontFace={new Font("rbxasset://fonts/families/SourceSansPro.json", Enum.FontWeight.SemiBold)} BorderSizePixel={0} BackgroundTransparency={1}/> <textbutton Text="Claim" BackgroundColor3={Color3.fromRGB(99, 102, 241)} TextColor3={Color3.fromRGB(255, 255, 255)} TextSize={14} Size={UDim2.fromOffset(96, 36)} BorderSizePixel={0}><uicorner CornerRadius={new UDim(0, 10)}/></textbutton> </frame>)}</__VelaBoundary.Consume>;}That preview is compiled against that config plus a
pinned rem, so a frame renders one pixel per pixel. It
extends rather than replaces, so the built-in text-slate-100 still resolves beside the new keys.
The Lowered tab shows each spliced in as the expression the config declared.
Values are source code, not values
Every theme value is an expression string written in the roblox-ts dialect. Not a colour object, not a hex string, not a number. Vela parses the string and splices the expression into the TSX it emits. Colours are "Color3.fromRGB(59, 130, 246)", and radius and spacing values are "new UDim(0, 6)". That is roblox-ts syntax, not Luau’s UDim.new(0, 6).
"#3b82f6" and 6 both fail, differently. A number is rejected at load. A string is never rejected: Vela splices in the first expression that parses, discards the rest, and emits no diagnostic.
Which one you get depends on how the text parses:
| You write | Emitted | Why |
|---|---|---|
"Color3.fromRGB(1, 2, 3)" | Color3.fromRGB(1, 2, 3) | Parses. The intended case |
"#3b82f6" | {"#3b82f6"} | # then a digit parses as nothing, so it falls back to a string literal — a roblox-ts type error on the next build |
"#a1b2c3" | {#a1b2c3} | # then a letter is a valid TypeScript private name, so it parses and is emitted bare |
"Color3.fromRGB(1, 2, 3) // blue" | Color3.fromRGB(1, 2, 3) | The call parses; the trailing text is dropped without a word |
"foo bar" | foo | First expression wins, bar is discarded |
A hex colour is a type error or a mystery identifier, depending on whether the character after # is a digit or a letter. Trailing junk disappears silently.
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 is the static path. A class resolved at runtime carries your theme as string data. 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, so keep every theme value in those two literal forms.
Only what you changed travels
The emit does not carry your whole palette. @rbxts/vela-runtime holds the default theme
itself, and a transformed module sends only the entries that differ:
theme = { colors = {}, radius = {}, spacing = {}, fontFamily = {}, rem = { … } }An untouched family sends nothing. Overriding one shade sends that whole colour family, so the
shades around it survive the merge. A top-level theme.colors replaces the scale, so it travels
whole and names itself in theme.replaced, telling the runtime not to merge its defaults back
underneath. None of this changes what a class resolves to.
Extend merges, top-level replaces
theme.extend.X merges over the built-in defaults. Your keys win on collision, everything else survives. theme.X at the top level replaces the entire scale, so writing theme.colors drops all 28 built-in families.
For colours the merge is deeper than a key swap. extend merges family by family, and shade by shade when both sides are palettes, so extending blue with only a 400 keeps the other ten shades. A literal replaces a palette wholesale, and vice versa.
When a top-level theme.colors is present, theme.extend.colors is thrown away without a warning. The same trap applies to every other axis. Use theme.extend.colors alone to add colours. Set both and every built-in palette disappears. The colours you thought you were adding never resolve either, so bg-brand-500 fails 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 want to start from an empty scale. A locked-down design
system, where referencing bg-slate-700 should be an error.
Rem
Every pixel offset a utility lowers is a rem unit rather than a raw pixel, so p-4, w-40,
rounded-lg and text-sm all follow the viewport. One rem is 16px at 1920×1020 and scales from
there. It is on by default, with no provider to mount and no hook to call.
export default defineConfig({ theme: { rem: { base: 16, min: 8, max: 64, baseResolution: { x: 1920, y: 1020 }, }, },});The curve follows
Littensy’s rem provider.
It measures the viewport diagonal against baseResolution and caps the width at 19:9, so an
ultrawide does not inflate the scale. It falls off more gently in portrait, then rounds and clamps
into [min, max].
rem is the one axis where extend and replace are the same thing. It is a record of settings rather
than a scale of keys, so both merge field by field.
What rem does not touch
Scale-valued utilities. w-full, h-1/2 and translate-x-1/2 stay fractions of the parent, and
colours, alignments, LayoutOrder and ZIndex have no pixel in them. TextSize is scaled with a
ceiling of 100, where Roblox stops honouring it.
Pinning offsets back to literal pixels
Close the clamp:
export default defineConfig({ theme: { rem: { min: 16, max: 16 } },});With no room left in it every viewport resolves the same rem, and the compiler drops the scaling
from the emit entirely. Offsets lower to plain UDim2/UDim literals with no binding and no
scaler, exactly as before rem existed:
<frame Size={UDim2.fromOffset(160, 0)}> <uipadding PaddingTop={new UDim(0, 16)} … /></frame>Pinning somewhere other than base is still a scale, so it keeps the binding. An inverted clamp
collapses onto min during config resolution rather than erroring in-game.
A SurfaceGui is pinned already. It takes its pixel space from the part it is drawn on, and a
BillboardGui sizes itself the same way. rem.pinnedUnder names those containers,
["surfacegui", "billboardgui"] by default, and what is written under one lowers to literal
offsets. Emptying the list puts both back on the curve. A container the compiler never sees needs
the closed clamp above.
What the emit looks like
A statically lowered element has no render to re-run when the viewport changes. It carries its offsets as bindings, and the file builds a scaler above it:
import { createVelaRemScaler } from "@rbxts/vela-runtime";const __VelaRem = createVelaRemScaler({ base: 16, min: 8, max: 64, baseResolution: { x: 1920, y: 1020 } });
<frame Size={__VelaRem.scale(UDim2.fromOffset(160, 0), 4)}> <uipadding PaddingTop={__VelaRem.scale(new UDim(0, 16), 0)} … /></frame>The number beside each value is a slot, so the file allocates one binding per scaled offset rather
than one per read. TextSize goes through scaleText, where the 100 ceiling applies. An element
that already needs the runtime host keeps plain values and is handed the names of the props to
scale.
A place tuned by eye at 1920×1080 keeps its proportions there and gains them everywhere else. If you have layouts you cannot re-check now, pin the clamp above and turn rem on deliberately later.
Semantic colors and shade palettes
A colour 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 run from 50 to 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, so
bg-brand works with no shade. Every built-in palette ships one mirroring its 500, so bg-slate,
text-blue and from-sky all resolve without any config.
DEFAULT is a config key, not a class name: bg-slate-DEFAULT is read as the semantic key
slate-DEFAULT and reported as unknown-theme-key. Completions offer the bare family name instead.
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)", }, }, },}split_color_key decides which one a class name means. It splits the payload at the last hyphen
and treats the trailing part as a shade only if it is exactly one of the eleven tokens. Otherwise
the whole payload is one semantic key.
So bg-my-color looks up the semantic key my-color, not the family my with the shade color.
Multi-word names are safe. The only ones you cannot use 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, or asking a palette for a shade it does
not define, is color-invalid-shade. A name that is not in the theme at all is unknown-theme-key.
What ships by default
Twenty-six colour palettes at eleven shades each, every one carrying a DEFAULT that mirrors its
500, plus the literals black and white. Ten radius keys and a DEFAULT. One spacing key,
"4". Three font families, five breakpoints and the rem curve.
The configuration reference lists every key and value. Two things about those defaults are worth knowing here.
The spacing scale is arithmetic, not data. A key not found in theme.spacing is multiplied by
4. p-1.5 is 6px and p-40 is 160px, without either being in the theme. Add spacing keys only for
a named step like gutter. Because "4" is a real entry, redefining it changes p-4 while
leaving p-3 and p-5 on the arithmetic path.
screens and fontFamily are the two exceptions to this page’s rule. Their values are plain
numbers and asset paths, not expression strings. font-* looks up family keys after the fixed
weight names, so avoid naming one after a weight — a fontFamily.bold would never resolve.
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 is hardcoded editor sugar. Any other multiple of 0.5 works just as well.
See also
- Configuration reference — the full schema, config discovery, load behavior, and plugins.
- Colors and surfaces — the utilities that consume
theme.colorsandtheme.radius. - Layout and sizing — the utilities that consume
theme.spacing. - Text and fonts — the utilities that consume
theme.fontFamily.