# Theming

> Expression strings rather than values, the extend-versus-replace rules, rem scaling, and the defaults.

Source: https://docs.astra-void.xyz/vela-rbxts/guides/theming/

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`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#plugins) and
[`presets`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#presets).

```ts title="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)",
        },
        surface: "Color3.fromRGB(24, 24, 27)",
      },
      radius: { card: "new UDim(0, 10)" },
      spacing: { gutter: "new UDim(0, 20)" },
    },
  },
});
```

_Interactive preview: bg-surface, rounded-card, p-gutter and bg-brand-500 — all four keys come from the config above._

That preview is compiled against that config plus a
[pinned `rem`](#pinning-offsets-back-to-literal-pixels), 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.

> **A bad theme string fails in one of three ways, none of them loud**
>
> 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.

```ts title="vela.config.ts"
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 },
    },
  },
});
```

> **The runtime path understands only two exact shapes**
>
> 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:

```lua title="The config in an emitted file, for a project that changed nothing"
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.

> **theme.colors silently discards theme.extend.colors**
>
> 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`.

```ts title="vela.config.ts"
// 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.

```ts title="vela.config.ts — the defaults, written out"
export default defineConfig({
  theme: {
    rem: {
      base: 16,
      min: 8,
      max: 64,
      baseResolution: { x: 1920, y: 1020 },
    },
  },
});
```

The curve follows
[Littensy's rem provider](https://github.com/littensy/slither/blob/main/src/client/providers/rem-provider.tsx).
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:

```ts title="vela.config.ts"
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:

```tsx title='className="p-4 w-40" with the clamp closed'
<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:

```tsx title='className="p-4 w-40" with rem active'
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.

> **This changes rendering on any viewport but the base one**
>
> 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.

```ts title="vela.config.ts"
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](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#the-default-theme) 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.

> **The editor's spacing suggestions are not the theme**
>
> 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](https://docs.astra-void.xyz/vela-rbxts/reference/config.md) — the full schema, config discovery, load behavior, and [plugins](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#plugins).
- [Colors and surfaces](https://docs.astra-void.xyz/vela-rbxts/guides/colors-and-surfaces.md) — the utilities that consume `theme.colors` and `theme.radius`.
- [Layout and sizing](https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing.md) — the utilities that consume `theme.spacing`.
- [Text and fonts](https://docs.astra-void.xyz/vela-rbxts/guides/text-and-fonts.md#family) — the utilities that consume `theme.fontFamily`.
