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.
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.
Vela’s theme.colors replaces the family set, stripping the ramps you reach for outside Facet components. theme.extend adds to them.
The tokens
Nineteen, and every Facet component names only these:
| Role | Pair |
|---|---|
| Page | background · foreground |
| Raised surface | card · card-foreground |
| Floating surface | popover · popover-foreground |
| Emphasis | primary · primary-foreground |
| Second emphasis | secondary · secondary-foreground |
| Recessive | muted · muted-foreground |
| Hover / highlight | accent · accent-foreground |
| Danger | destructive · destructive-foreground |
| Lines | border · 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:
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
Vela resolves className at compile time, so bg-primary becomes a literal Color3 in the emitted Luau. A build carries exactly one theme, and facetTheme({ mode: "dark" }) is a decision made by the compiler.
There is no useTheme() and no way to flip a settings toggle to light mode with the components as
they are. The previews on this site show it: flipping the docs’ light/dark toggle reloads the frame
against a different build rather than repainting the running one.
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
# edit vela.config.ts: base: "zinc" → base: "stone"npx rbxtscThat 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.