Facetguides

Theming

Nineteen semantic tokens, four neutral ramps, and why a build carries exactly one mode.

Roblox has no CSS variables, so the indirection that makes shadcn/ui themeable lives one layer down — in vela.config.ts, resolved at compile time.

vela.config.ts
import { defineConfig } from "vela-rbxts";
import { facetTheme } from "@facet-ui/theme";
export default defineConfig({
theme: {
extend: {
...facetTheme({ base: "zinc", mode: "dark" }),
},
},
});

facet init writes exactly this. facetTheme() returns a plain object, a colors map and a radius scale, spread into theme.extend.

The tokens

Nineteen, and every Facet component names only these:

RolePair
Pagebackground · foreground
Raised surfacecard · card-foreground
Floating surfacepopover · popover-foreground
Emphasisprimary · primary-foreground
Second emphasissecondary · secondary-foreground
Recessivemuted · muted-foreground
Hover / highlightaccent · accent-foreground
Dangerdestructive · destructive-foreground
Linesborder · input · ring

Nothing in the registry says zinc-800. That is the mechanism: because components name roles, base: "slate" rethemes every copied component.

facet doctor checks it from the other side. It collects the tokens the installed components declare and verifies they resolve in your config. A missing one is reported by name, rather than as a Vela diagnostic on a file you never wrote.

Bases

Four neutral ramps, each a full 50–950 scale: zinc (default), slate, stone, neutral. destructive comes from a red that is not part of the ramp — rgb(239, 68, 68) in light, rgb(220, 38, 38) in dark.

The mapping is mechanical. In dark mode:

background ramp[950] primary ramp[50]
foreground ramp[50] primary-foreground ramp[900]
card ramp[900] secondary ramp[800]
muted ramp[800] muted-foreground ramp[400]
border ramp[700] input ramp[700] ring ramp[300]

border and input sit one step lighter than the dark surfaces they are drawn on. At ramp[800] an outline button was almost indistinguishable from a ghost one.

Light mode inverts it: background and card are white, foreground is ramp[950], primary is ramp[900], border and input are ramp[200].

Radius

facetTheme({ radius: "new UDim(0, 12)" })

Sets what rounded-*’s DEFAULT resolves to, defaulting to new UDim(0, 8). It is a string because it is a Luau expression Vela emits verbatim.

Overriding

facetTheme() returns a plain object, so overriding is spreading:

vela.config.ts
import { defineConfig } from "vela-rbxts";
import { facetTheme } from "@facet-ui/theme";
const base = facetTheme({ base: "slate", mode: "dark" });
export default defineConfig({
theme: {
extend: {
...base,
colors: {
...base.colors,
primary: "Color3.fromRGB(88, 101, 242)",
"primary-foreground": "Color3.fromRGB(255, 255, 255)",
},
},
},
});

Spread base.colors before your overrides — dropping it replaces the whole map, and every token you did not restate stops resolving.

Colour values are Luau expressions as strings. Color3.fromRGB(…), Color3.fromHex("#5865F2"), anything Vela will emit.

One mode per build

This is a real gap and an open decision. Facet does nothing about it today. The honest fix is for Vela to lower bg-primary to a read from a runtime token table rather than to a literal, and Facet cannot make that change unilaterally. The tempting middle option, a runtime ThemeProvider alongside classes, splits the styling story in two. The failure mode is a component whose background is themeable and whose border is not.

Until then, a runtime toggle belongs in your copy of the components, applied as instance props rather than classes, the same way Button handles its disabled fade.

Retheming without touching a component

Try another ramp
# edit vela.config.ts: base: "zinc" → base: "stone"
npx rbxtsc

That is the whole procedure. If changing a ramp requires editing a component, that component named a ramp step somewhere it should have named a role — see Component conventions.