# Vela > Tailwind-style className compilation for roblox-ts React interfaces. - Docs: https://docs.astra-void.xyz/vela-rbxts/ - Source: https://github.com/astra-void/vela-rbxts # Vela > Tailwind-style className compilation for roblox-ts UI — React or Vide. Source: https://docs.astra-void.xyz/vela-rbxts/ Vela adds `className` to `roblox-ts` UI elements and compiles it away. You write Tailwind-shaped utility classes on Roblox host elements and on your own components. A TypeScript transformer rewrites them into Roblox properties and helper instances before roblox-ts emits Luau. `p-4` becomes a `UIPadding` child, `rounded-lg` a `UICorner`, `bg-slate-700` a `BackgroundColor3`. Vela emits for [React](https://github.com/littensy/rbxts-react) by default, and for [Vide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) when your `tsconfig.json` compiles Vide JSX. Nothing survives into the shipped game unless a class needs the player's screen or input device to resolve. _Interactive preview: Compiled by the real Vela compiler and rendered live. The Classes tab is the source as written; the Lowered tab is what rbxtsc emits._ Every class on that card resolves at build time. **Classes** is the component as written. **Lowered** is the output: plain Roblox properties, a few helper instances, no trace of Vela. Every preview in these docs comes from the real compiler. ## What you get **Static by default.** A plain string of classes lowers entirely at compile time. The `className` attribute disappears, the element keeps its tag, and Vela's cost is paid once on your machine. Two things opt an element into runtime work: a `className` the compiler cannot read, and [variants](https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants.md), which depend on the player's device. A class list that only *branches* still resolves at compile time, diagnostics intact. **Diagnostics instead of silence.** A class Vela cannot lower is reported, not dropped. `tracking-wide` warns that Roblox has no letter spacing. `bg-brand` says the palette needs a shade. A typo in a theme key names the scale it searched. The [diagnostics reference](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) lists every code. A few cases still fall through silently, and the docs flag each one where it lives. **Editor tooling from the same compiler.** The `vela-rbxts-lsp` language server links the same Rust crate the build uses, so completions, hover, swatches, quickfixes and squiggles agree with the compiler by construction. See [editor setup](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md). **A Roblox-shaped vocabulary.** Vela is not Tailwind parity. It implements the slice that maps onto what Roblox gives you: colours, gradients, strokes, corners, padding, gap, sizing, position, flex layout and typography. Everything else is rejected loudly rather than approximated. The edges are mapped in [scope and status](https://docs.astra-void.xyz/vela-rbxts/getting-started/scope-and-status.md). ## Get started Read these in order: 1. [Installation](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md) — the package, the tsconfig plugin, the config file, and a smoke test. 2. [Your first component](https://docs.astra-void.xyz/vela-rbxts/getting-started/first-component.md) — build a panel and read what each class lowered to. 3. [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md) — the pipeline and the two paths a `className` can take. 4. [Scope and status](https://docs.astra-void.xyz/vela-rbxts/getting-started/scope-and-status.md) — what is deliberately absent, and the gaps that do not announce themselves. To skip setup, the [playground](https://docs.astra-void.xyz/vela-playground/) runs the real compiler in the browser. Type class strings and watch them lower with no project at all. ## Next - **Building something.** [Recipes](https://docs.astra-void.xyz/vela-rbxts/guides/recipes.md) covers buttons, badges, meters, list rows and a dialog, each with a live preview. - **Styling and layout.** See [theming](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md), [layout and sizing](https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing.md), [colours and surfaces](https://docs.astra-void.xyz/vela-rbxts/guides/colors-and-surfaces.md) and [text and fonts](https://docs.astra-void.xyz/vela-rbxts/guides/text-and-fonts.md). - **Writing Vide.** [Vide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) covers the whole of the difference. - **A class did nothing.** [Troubleshooting](https://docs.astra-void.xyz/vela-rbxts/guides/troubleshooting.md) is organised by symptom. - **Looking something up.** The [utilities reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md) lists every class and the values it accepts. [Configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md), [diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) and the [API](https://docs.astra-void.xyz/vela-rbxts/reference/api.md) cover the rest. --- # Installation > Packages, the two ways to run Vela, the config file, and the ambient declaration. Source: https://docs.astra-void.xyz/vela-rbxts/getting-started/installation/ Vela is a compile-time utility-class system for `roblox-ts` UI — React by default, [Vide](https://centau.github.io/vide/) when the project says so. Write Tailwind-shaped class names on Roblox host elements and your own components, and Vela rewrites them into Roblox properties and helper instances before roblox-ts emits Luau. It is a compiler over your `.tsx` files, not a component library. **Where that compiler runs is yours to pick** — a plugin inside `rbxtsc`, or a command that runs before it. ## Install the packages ```bash pnpm add vela-rbxts ``` Then the ordinary roblox-ts stack, which Vela does not bring for you. Install the UI library you actually write JSX with — React: ```bash pnpm add @rbxts/react @rbxts/react-roblox @rbxts/services ``` …or [Vide](https://centau.github.io/vide/), which needs no Vela config of its own — the target is read off the `jsxFactory` your `tsconfig.json` already sets, see [the Vide guide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md): ```bash pnpm add @rbxts/vide @rbxts/services ``` ```bash pnpm add -D roblox-ts typescript@5.9.3 @rbxts/compiler-types @rbxts/types ``` The `typescript@5.9.3` pin is deliberate: Vela calls into **your** TypeScript install, and 7.x fails on the first file. Nothing enforces it. > **pnpm needs `nodeLinker: hoisted`** > > Vela's runtime reaches your place *transitively*, and pnpm's isolated linker does not put it where roblox-ts and Rojo look. Set the linker once, at the workspace root: > > ```yaml title="pnpm-workspace.yaml" > nodeLinker: hoisted > ``` > > npm and Yarn hoist by default. A nested `.npmrc` will not do it — pnpm reads `nodeLinker` only from the workspace root. **What the TypeScript pin protects you from, and the rest of the version matrix** Vela's host adapter resolves `typescript` from **your** install and calls into its API. TypeScript 7 moved enough of that API to break the call. A plain `typescript` install today gets 7.x and a build that dies on the first file: ```text title="rbxtsc output with typescript@7" src/client/Panel.tsx:1:1 - error TS89000: [@vela-rbxts/rbxtsc-host] compiler-invocation-failed: Cannot read properties of undefined (reading 'Latest') ``` `5.9.3` is what the repo pins and tests against, and `6.x` also builds. No package declares a `peerDependency`, so your package manager will not warn you, and the error names neither TypeScript nor a version. The rest of what Vela is developed against: **Node 24** in CI, **roblox-ts `^3.0.0`**, `@rbxts/compiler-types ^3.0.0-types.0`, `@rbxts/types ^1.0.935`, `@rbxts/react ^17.3.7-ts.1`, and `@rbxts/services ^1.6.0`. **Why no runtime host appears in those commands** An element Vela could not settle at compile time imports a **runtime host**: `@rbxts/vela-runtime` for React, `@rbxts/vela-runtime-vide` for Vide. Both ship as dependencies of `vela-rbxts`, and each declares *its* UI library as an optional peer. The host you do not emit for is one inert ModuleScript. `@rbxts/services` matters beyond your own imports. The host reads `{ UserInputService, Workspace }` from it for viewport, input mode and rem, so it must resolve in the built place. `@rbxts/react-roblox` mounts a React tree, and Vide mounts itself. ## Choose how Vela runs Two ways to run it, the same compiler either way. **The transformer** registers a plugin so the lowering happens inside `rbxtsc`. **The CLI** lowers your sources into a second tree first, which `rbxtsc` then compiles with no plugin. They differ in where the work sits in your build: | | Transformer | CLI | |---|---|---| | Setup | One `tsconfig.json` plugin entry | `rootDir`/`include` point at the generated tree | | Build command | `rbxtsc` | `vela build && rbxtsc` | | Watch | `rbxtsc -w` | `vela watch` beside `rbxtsc -w` | | Sources `rbxtsc` sees | Yours | The generated mirror | | Lowered output | Never written to disk | A reviewable tree you can read and diff | | Needs a plugin-capable toolchain | Yes | No | | Processes to keep running | One | Two | | Available since | 0.1.0 | 0.11.0 | Pick the transformer when `rbxtsc` is your build. Pick the CLI when a plugin is not an option, such as a pinned toolchain or a build system driving `tsc` itself. It also fits when you want the lowered sources as an artifact. Everything after this section is shared: the config file, the type declaration, and the smoke test all work the same on both. ### Track A — the transformer Add one entry to `compilerOptions.plugins` in your existing `tsconfig.json`: ```json title="tsconfig.json" { "compilerOptions": { "plugins": [{ "transform": "vela-rbxts/transformer" }] } } ``` That is the entire tsconfig change — Vela requires no compiler option of its own. Without a project yet, start from [roblox-ts's quick start](https://roblox-ts.com/docs/quick-start). **Why the entry names a /transformer subpath, and what else goes in it** `vela-rbxts/transformer` is a separate export from the package root: the root gives you `defineConfig` and types, the subpath is the callable roblox-ts consumes. The plugin entry object is also where transformer options go: file filters, a diagnostic code base, a project root. All are covered in the [configuration reference](https://docs.astra-void.xyz/vela-rbxts/reference/config.md). ### Track B — the CLI Point `rbxtsc` at the tree the CLI generates, and register no plugin: ```json title="tsconfig.json" { "compilerOptions": { "rootDir": ".vela/src", "baseUrl": ".vela/src", "outDir": "out" }, "include": [".vela/src"] } ``` `vela build` mirrors `src` into `.vela/src`, transforming the `.tsx` files that use `className` and copying everything else byte for byte. It writes a `.gitignore` beside the tree: ```bash title="Build" npx vela build && npx rbxtsc -p tsconfig.json ``` If a plugin entry is still registered, or `rootDir` still points at `src`, `vela build` says so on every run. The [CLI reference](https://docs.astra-void.xyz/vela-rbxts/reference/cli.md) has every flag. **Edit src, never the generated tree** The mirror is a build output: an edit inside `.vela/src` survives until the next run touches that file, with no warning when it disappears. Your editor and `tsc --noEmit` point at the generated tree too, but the language server still reads your real sources. ## Add `vela.config.ts` Optional — with no config file Vela uses its default theme and infers the framework from `tsconfig.json`. Add it when you want your own theme values: ```ts title="vela.config.ts" import { defineConfig } from "vela-rbxts"; export default defineConfig({ theme: { extend: { colors: { brand: { 500: "Color3.fromRGB(59, 130, 246)", 700: "Color3.fromRGB(29, 78, 216)", }, }, radius: { panel: "new UDim(0, 10)", }, }, }, }); ``` Two things to get right. Every value is an **expression string in the roblox-ts dialect**, and it goes under `theme.extend`. A top-level `theme.colors` replaces the whole registry *and* discards `theme.extend.colors` silently. See the [theming guide](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md). **Where the file goes, and where Vela looks for it** Vela walks up from each source file's directory looking for `vela.config.ts` or `vela.config.json`, the `.ts` form winning when both exist. There are no `.js`, `.mjs` or `.cjs` variants and no `package.json` key. Keep it at the project root, **outside** `rootDir` — Vela loads it with Node, so it is build tooling rather than game code. Dropped into `src/`, roblox-ts sweeps it up and fails the build: ```text title="rbxtsc output" src/vela.config.ts:1:30 - error TS roblox-ts: You cannot use modules directly under node_modules. 1 import { defineConfig } from "vela-rbxts"; ~~~~~~~~~~~~ ``` Note that this is the opposite of the `.d.ts` file below, which *must* live inside `rootDir`. One wrinkle at the root: a roblox-ts `tsconfig.json` includes only `src`, so typed ESLint reports `vela.config.ts` as outside the project. Widening `include` hands the file back to roblox-ts — use an ESLint override for that path, or the JSON form. ## Declare the `className` prop Roblox host elements have no `className` prop in `@rbxts/types`. Vela augments `React.Attributes` globally, which takes effect only if the package is imported for its side effect somewhere in your program: ```ts title="src/vela-env.d.ts" import "vela-rbxts"; ``` Without it every `className` is a type error, even though Vela would still have processed it. Keep the file inside `src` — under the CLI, `.d.ts` files are copied through unchanged. > **Do not name the file src/vela-rbxts.d.ts** > > A roblox-ts project sets `baseUrl` to `src`, and TypeScript tries `baseUrl` before `node_modules` for a bare specifier. Under that name, `import "vela-rbxts"` resolves to the declaration file itself. The augmentation never loads, and `className` stays a type error with nothing pointing at the cause. Any other name works. ## Install the language server Vela ships a language server, `vela-rbxts-lsp`, built from the same compiler crate the build uses. No build needs it, but one class of mistake is reported **only** here: `text-*` on a `frame`, `image-*` on a label, `placeholder-*` outside a `textbox`. The transform pipeline never checks any of it, so those compile clean and write the wrong property onto the wrong instance. In VS Code, install **Vela LSP**, id `astra-void.vela-rbxts-lsp`, publisher `astra-void`: ```bash title="VS Code" code --install-extension astra-void.vela-rbxts-lsp ``` It needs VS Code `^1.91.0`, and that is the whole install — the marketplace serves a per-platform VSIX with the server binary inside it. The extension activates on TypeScript and TSX documents: open a `.tsx` file and the server starts. **How your theme reaches the server** The extension watches `vela.config.{ts,json}`, evaluates every match and pushes the result over the wire — the server never reads the file itself. The `.ts` form goes through **your project's** `typescript`, so the pin at the top of this page decides whether your editor resolves custom theme keys too. **Any other editor** The server speaks LSP over stdio and has no VS Code dependency. Add it to the project: ```bash pnpm add -D @vela-rbxts/lsp ``` Then point your client's server command at it: ```bash title="Server command" npx --package @vela-rbxts/lsp vela-rbxts-lsp ``` `--package` is not optional. The binary is named `vela-rbxts-lsp`, but the package owning it is `@vela-rbxts/lsp`. `vela-rbxts-lsp` alone is the VS Code extension id, published as a VSIX. The platform binary arrives as an optional dependency picked by `os`/`cpu`. Linux arm64 musl and Windows on ARM have no prebuilt binary on either channel and need a source build. **Nothing hands the server your config here.** Passing the resolved theme is the client's job, through `initializationOptions`. Skip it and the server falls back to the default theme, so every custom key reports `unknown-theme-key` against classes that build perfectly well. [Editor setup](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md) has the payload shape, the method that updates it after startup, and the full list of what the server does and does not implement. ## Build and serve On track A there is no Vela-specific build step — compile with roblox-ts as usual: ```bash title="Build — track A" npx rbxtsc ``` On track B, lower first. `vela build` exits non-zero when a file fails to compile, so `&&` is the right joint: ```bash title="Build — track B" npx vela build && npx rbxtsc ``` Either way, sync `out/` into Studio with Rojo the same way you already do: ```bash title="Serve" rojo serve ``` You need no Vela-specific Rojo mapping. **Why the runtime import resolves with no project change** Most of what Vela produces is a Roblox property or a helper instance emitted as a JSX child. The rest is an import of `@rbxts/vela-runtime`, one ModuleScript the whole place shares. It resolves because roblox-ts only resolves a package whose scope directory is one of the project's `typeRoots`. `node_modules/@rbxts` is the one every roblox-ts project lists and every Rojo template maps to `ReplicatedStorage`, the same route `@rbxts/react` takes. This is what the `nodeLinker` note is about: the directory has to be real for either tool to walk into it. ## Check that it actually took Two of the three ways this setup can fail are silent, so spend one build proving it worked. Put a broken class on any `.tsx` element: ```tsx title="src/client/Smoke.tsx" ``` Build and read the output, looking for a warning naming `nope-500` — track A routes it through roblox-ts's numbered diagnostics, track B prints it itself: ```text title="Track A — rbxtsc output" src/client/Smoke.tsx:4:28 - warning TS89000: [@vela-rbxts/compiler] unknown-theme-key: Unknown theme key "nope-500" for background color utility in className literal. 4 return ; ~~~~~~~~~~~ ``` ```text title="Track B — vela build output" client/Smoke.tsx:4:28 - warning vela/compiler(unknown-theme-key): Unknown theme key "nope-500" for background color utility in className literal. ``` If you get the warning, everything is wired: Vela is running and diagnostics are reaching you. Delete the file. **No warning — what to check** **`className` is also a type error in your editor.** The ambient declaration is not loading. Check that the `.d.ts` file exists inside `src` and is not named `src/vela-rbxts.d.ts`. **`className` type-checks fine.** On track A, either the transformer is not running, or your roblox-ts does not expose `context.addDiagnostic` and is discarding every Vela warning. For the first, check that the `plugins` entry spells the `/transformer` subpath and the file ends in `.tsx`. The second makes broken classes look clean forever. On track B, check you ran `vela build` and that its header names the directory you expected. **What the emitted file should look like** Check the emitted file: `out/` on track A, `.vela/src` on track B. `p-4` should have become a `` child, and the `className` attribute should be gone. If the attribute survived, the element was skipped. The padding values read as `__VelaRem.scale(new UDim(0, 16), 0)` rather than a bare `new UDim(0, 16)`, and the file grows an import of `@rbxts/vela-runtime`. That is [rem](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem): `16` is the value at the base viewport, and the scaler follows the real one. ## Next steps - [Your first component](https://docs.astra-void.xyz/vela-rbxts/getting-started/first-component.md) — write a panel, put it on screen, and read exactly what each class lowered to. - [Editor setup](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md) — what the server provides, the three settings it contributes, sorting class names on save, and configuring a non-VS Code client. - [The playground](https://docs.astra-void.xyz/vela-playground/) — compile class strings in the browser against the real compiler, with no project at all. - [Vide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) — what changes when the project emits for Vide instead of React, and the two limits that are inherent to it. --- # Your first component > Build a small panel with Vela utilities and read the Roblox properties and helper instances it lowers to. Source: https://docs.astra-void.xyz/vela-rbxts/getting-started/first-component/ The fastest way to understand Vela is to write a small component and read what comes out. This page builds a panel with a title and a caption, then walks through what each class name became. _Interactive preview: The panel this page builds, compiled by Vela and rendered in the browser._ That preview is not a screenshot. The example was lowered by the same compiler `rbxtsc` loads. **Classes** is the source with `className` on it, **Lowered** is what came out, and that source is the snippet below, line for line. The **Lowered** tab also shows `BackgroundTransparency` and `BorderSizePixel` on elements whose classes mention neither. That is [preflight](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight). Roblox paints every `GuiObject` as an opaque grey box, so an element carrying a `className` starts transparent and borderless unless something paints it. ## The component ```tsx title="src/client/Panel.tsx" import React from "@rbxts/react"; export function Panel() { return ( ); } ``` Every class here resolves at compile time. The one thing that survives into the output is the rem scaler, which makes those offsets follow the player's viewport. [What comes out](#what-comes-out) shows it. > **Why every element carries a size** > > `h-24` on the panel and `w-full h-6` on the labels are Roblox defaults showing through, not Vela requirements. A `GuiObject` starts at `Size = (0, 0, 0, 0)`. A class list that only sets a width leaves the height at zero, and the panel compiles cleanly to an invisible frame containing two invisible labels. Use `w-fit`/`h-fit` to lower to `AutomaticSize` instead. ## Put it on screen Vela changes nothing about how you mount a component. This is ordinary `@rbxts/react-roblox`. The one Vela-specific detail is the wrapper: `screengui` is **not** one of the eight host elements Vela lowers, so classes belong on a `frame` inside it. ```tsx title="src/client/main.client.tsx" import React from "@rbxts/react"; import { createPortal, createRoot } from "@rbxts/react-roblox"; import { Players } from "@rbxts/services"; import { Panel } from "./Panel"; const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui"); const root = createRoot(new Instance("Folder")); root.render( createPortal( , playerGui, ), ); ``` Rojo maps this file into `StarterPlayer/StarterPlayerScripts` like any other client entry point. The `.client` in the filename is what makes roblox-ts emit a LocalScript. Note the extension too: Vela only transforms `.tsx` files. Build, sync, and press play: the panel from the preview above is now in your place. ## What each class lowers to The `frame` classes split two ways. Some set properties on the Frame. The rest become helper instances, because Roblox models layout, padding, corners and strokes as separate objects. | Class | Result | Value | |---|---|---| | `flex` | `UIListLayout` child, `FillDirection` | `Enum.FillDirection.Horizontal` | | `flex-col` | `UIListLayout.FillDirection` | `Enum.FillDirection.Vertical` | | `gap-4` | `UIListLayout.Padding` | `new UDim(0, 16)` | | `p-4` | `UIPadding` child, all four sides | `new UDim(0, 16)` | | `rounded-lg` | `UICorner.CornerRadius` | `new UDim(0, 8)` | | `bg-slate-800` | `BackgroundColor3` | `Color3.fromRGB(29, 41, 61)` | | `border` | `UIStroke.Thickness` | `1` | | `border-slate-700` | `UIStroke.Color` and `Transparency` | `Color3.fromRGB(49, 65, 88)`, `0` | | `w-80 h-24` | `Size` | `UDim2.fromOffset(320, 96)` | Every offset in that column is a **rem** value at the base viewport, not a fixed pixel count. `new UDim(0, 16)` is what `p-4` is worth at 1920×1020. See [rem](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem). A few of those deserve a note. `flex` and `flex-col` write to the same instance. Bare `flex` sets `FillDirection` to `Horizontal`, and `flex-col` overwrites it with `Vertical`. Only one `UIListLayout` is emitted however many layout classes you stack, so `flex-col` alone is enough for a column. `gap-4` and `p-4` both resolve `4` through the theme's `spacing` scale, where the built-in default has its only entry. Every other numeric key comes from an arithmetic fallback: a non-negative multiple of `0.5`, times four pixels. That is why `w-80` is 320 pixels rather than a lookup failure. `w-80` and `h-24` merge into one `Size` rather than one replacing the other, producing a single `UDim2.fromOffset(320, 96)`. Drop the `h-24` and the height falls back to zero, so the panel disappears. On the runtime path the merge does not happen and the later token wins. `border` and `border-slate-700` are two utilities sharing a prefix. The bare form sets thickness. The colour form sets colour and transparency, so `border-slate-700` alone gives a coloured stroke with no explicit thickness. The `textlabel` classes are mostly text-only. `text-slate-100` sets `TextColor3`, `text-lg` sets `TextSize` to `18`, `text-left` sets `TextXAlignment` to `Left`, and `font-semibold` sets `FontFace`. Their `w-full h-6` goes through the same sizing path as the panel's, mixing a scale and an offset into `new UDim2(1, 0, 0, 24)`. `text-*` is disambiguated by value, not prefix. A known size key becomes `TextSize`, an alignment keyword becomes `TextXAlignment`, and anything else is read as a colour. Misspell a size and you get a theme-key warning about a colour. ## What comes out The transformer removes `className`, emits the resolved properties on the element, and prepends the helper instances ahead of your own children. Here is the emitted TSX, with line breaks added: ```tsx title="Transformed output (line breaks added; nothing else changed)" import { createVelaRemScaler, __VelaOpacity } from "@rbxts/vela-runtime"; const __VelaRem = createVelaRemScaler({ base: 16, min: 8, max: 64, baseResolution: { x: 1920, y: 1020 }, }); ``` Four things there were never in your class list. `BorderSizePixel` and the labels' `BackgroundTransparency` are [preflight](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight). The panel skips the transparency because `bg-slate-800` paints it. `SortOrder = LayoutOrder` is emitted on every Vela layout so `order-*` works. Every offset is wrapped in `__VelaRem.scale`. That is [rem](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem). The literal inside is the value at a 1920×1020 viewport, and the trailing number is a binding slot. `TextSize` goes through `scaleText`, which caps at 100. Pin `theme.rem` and the wrappers disappear, leaving plain literals. That is why the **Lowered** tab shows plain literals and this block does not. Every preview in these docs is compiled with rem pinned, to keep the render one pixel per pixel. One more thing is not shown above. A component definition's root is wrapped in `<__VelaOpacity.Fade>`, which is how an `opacity-*` on an ancestor reaches a statically lowered subtree. It renders no instance. The helpers are prepended, so they sort ahead of your own children. That does not affect layout, but it matters if you index children positionally. This is the **static path**. The element keeps its tag, no class list is parsed in game, and every token was resolved and checked at build time. The [pipeline page](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md) explains when that stops being true. ## Something that does not work Vela is a Roblox-shaped subset of Tailwind, not a port. The Roblox text engine exposes no letter spacing, so `tracking-*` cannot mean anything. ```tsx title="This warns" ``` The build still succeeds, but the transformer reports a warning through roblox-ts: ```text title="Compiler output" [@vela-rbxts/compiler] no-roblox-equivalent: Tailwind "tracking" utilities have no Roblox equivalent, so "tracking-wide" is ignored. ``` `p-4` and `bg-slate-800` are applied normally. Only the inexpressible token is dropped. A class Vela has never heard of gets `unsupported-utility-family` instead, so the two failure modes are distinguishable. Colours have their own failure mode. You reach a shade by naming it or through the palette's `DEFAULT`. A palette with neither is an error rather than a fallback. Say a `brand` family defines only `500` and `700`: ```tsx title="This warns too" ``` ```text title="Compiler output" [@vela-rbxts/compiler] color-missing-shade: Color palette "brand" for background color utility has no "DEFAULT" shade, so it requires an explicit shade such as "brand-500" in className literal. ``` Write `bg-brand-500`, or add a `DEFAULT` key. The built-in palettes all ship one mirroring their `500`, which is why `bg-slate` needs no shade while `bg-brand` does. > **Where the squiggle lands** > > For a static string `className` the compiler anchors each diagnostic to the token's real position. A computed `className` produces diagnostics with no range. The host adapter then falls back to the first textual occurrence of the token anywhere in the file, so the underline can land on an earlier comment or string. Diagnostics carrying no token at all are reported at the start of the file. The message and code are always correct. The full list of codes, and what each one means, is in the [diagnostics reference](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md). ## Next step Read [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md) for how the transformer fits into an `rbxtsc` build, and what makes it switch to a runtime path. --- # How it works > The pipeline end to end, where the transformer and CLI diverge, and the two lowering paths. Source: https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works/ Vela has no runtime you install. It is a host adapter that decides which files to touch, plus a native compiler that does the class-name work. [The two ways to run Vela](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md#choose-how-vela-runs) differ at exactly two points: what hands the adapter a file, and where the resulting text goes. Everything between is one shared pipeline, which is why both tracks emit the same Luau. ## The pipeline **Something hands the adapter a file.** Under the *transformer*, roblox-ts resolves the `tsconfig.json` plugin entry. It calls the exported factory with your `Program` and its own `typescript` instance. The factory throws without that instance, so plain `tsc` cannot drive it. Under the *CLI*, `vela build` walks `--src` itself and calls the same adapter per file, with no `Program` and no plugin options. **The host adapter filters files.** Five conditions, in order. A file that fails any is passed through untouched: 1. The filename ends in `.tsx`, case-insensitively. **`.ts` files are never transformed**, so a `className` built in a `.ts` helper is invisible to Vela. 2. `.d.ts` and `.d.tsx` are skipped. 3. Any path containing a `node_modules` segment is skipped. 4. The source text contains the literal substring `className`. 5. The source text matches a coarse JSX-open-tag regex. The last three are toggles (`skipNodeModules`, `requireClassName`, `requireJsxSyntax`), all on by default, and there is no glob support. **The adapter finds your config.** Vela walks up from each file's directory looking for `vela.config.ts` or `vela.config.json`. The `.ts` form wins when a directory has both. A TypeScript config is stripped of its `vela-rbxts` imports, transpiled, and executed in a scope where `defineConfig` and `defaultConfig` are injected. A TypeScript diagnostic throws, as does an export that is neither a resolved config nor an input-shaped object. Finding nothing gets you the default theme. See [config discovery](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#discovery) for the caching. **The native compiler parses and lowers.** It walks the JSX tree and resolves each class token against the merged theme. Out comes new source text plus a diagnostic list. **The text goes back out**, as text rather than an AST. Under the transformer it is parsed into a fresh `SourceFile` and substituted in memory. Diagnostics are handed back through roblox-ts's `addDiagnostic`. **On a roblox-ts that does not expose that hook they are dropped silently.** Under the CLI the text is written to the mirrored path. It is skipped when byte-identical, so `rbxtsc -w` is not woken. The CLI prints the diagnostics itself. ## What gets touched An element is lowered if it is a supported host element or a component. Vela recognizes exactly eight host elements: `frame`, `scrollingframe`, `canvasgroup`, `textlabel`, `textbutton`, `textbox`, `imagelabel`, `imagebutton` A component is any tag starting with an uppercase letter, such as ``, or any member expression such as ``, at any depth. A namespaced tag like `` is never a component. Anything else is skipped with its `className` left untouched, reporting `classname-on-unsupported-host`. That covers lowercase intrinsics Vela does not implement, like `screengui`, and namespaced tags. The warning names only the local part, so `` is reported as `rect`. ### `className` on components `` is lowered the way a host element is. On the static path the resolved props become ordinary JSX attributes on the component, `className` is removed, and helper instances are prepended as children. A self-closing component that needs helpers gains a closing tag: ```tsx title="In" ``` ```tsx title="Out (shape, not verbatim)" ``` > **The component has to forward what it does not consume** > > Vela hands the component props and children. It cannot make the component do anything with them. If `Box` does not spread its unrecognized props onto a Roblox host element and render its children, the styling is lost. **There is no diagnostic for that**, at compile time or in the editor. > > A component library can be built to receive this shape. [lattice-ui](https://docs.astra-void.xyz/lattice-ui/index.md) forwards unknown props from every part and re-parents Vela's helper instances under the element the props land on. [Styling Lattice with Vela](https://docs.astra-void.xyz/lattice-ui/guides/styling-with-vela.md) is a worked example. The eventual host element is unknown, so the per-element utility restrictions do not apply inside a component's `className`. The editor offers the full set there and never raises `unsupported-host-utility`. Whether `text-lg` means anything depends on which host your component renders, and nothing checks that. ## The two lowering paths Which one you get changes the shape of the output and whether anything runs at runtime. ### Static lowering The default. The compiler resolves every token to a concrete value, emits Roblox properties on the element, prepends helper instances as children, and removes the `className`. The element keeps its tag, and nothing from Vela survives into the running game. This path supports the full utility set. It also emits two properties you did not ask for. A host element carrying a `className` starts from `BackgroundTransparency = 1` and `BorderSizePixel = 0`. No class list has to paint over the opaque grey box Roblox gives every `GuiObject`. Anything that actually paints opts back out, and a background painted by a variant reopens it at runtime. Components are never preflighted. See [`preflight`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight). ### The runtime path When Vela cannot finish at compile time it swaps the element's tag for `VelaRuntimeHost`, imported from `@rbxts/vela-runtime` and configured once at the top of the module body. Static props it did resolve are emitted with an `as never` cast, since the host's prop type does not describe them. Conditional rules are serialized into `__velaRules`, their conditions into `__velaTests`, and the original tag travels as `__velaTag`. Structural work rides along in further internal props: `__velaTransition`, `__velaAnimation`, `__velaText`, `__velaMargin` and `__velaDivide`. For a host element, `__velaTag` is the tag string. For a component it is a live reference, `__velaTag={Box}`, so the host renders your component rather than an intrinsic. At runtime the host normalizes theme strings into `Color3` and `UDim` values. It reads the environment, applies whichever rules match, tokenizes any dynamic `className`, strips the internal props, and calls `React.createElement` with the original tag and the helper children. It reads `Workspace.CurrentCamera.ViewportSize` for width, height, orientation and rem. It reads `UserInputService.TouchEnabled` / `MouseEnabled` / `GamepadEnabled` for input mode, and `MouseEnter`/`MouseLeave` on the element for `hover:`. The host is a package, not a copy. One ModuleScript the whole place shares, over the target-neutral `@rbxts/vela-runtime-core`. A transformed module carries an import and its config: ```ts title="What a runtime-path module emits" import { createVelaRuntimeHost } from "@rbxts/vela-runtime"; const VelaRuntimeHost = createVelaRuntimeHost({ preflight: true, theme: { … }, plugins: { … } }); ``` A [Vide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) project imports `@rbxts/vela-runtime-vide` in the same position. > **Most files send an emptied theme** > > That config carries only what your project changed. The runtime holds the defaults itself, so an untouched scale sends `{}`. A file whose host never has to *parse* a class value sends its scales emptied entirely. One that does, through a computed `className` or a host taking a spread, keeps the full tables. `preflight`, `theme.rem` and the motion driver stay either way, and an emptied table is marked in `theme.replaced` so the runtime takes it as given. ### What triggers the runtime path Any one of these is sufficient. **A `className` expression that does not collapse to one static token list.** A template literal with an interpolation, a variable, a call, a spread. A **branch** is on this list only because it needs the host to read its conditions. `a ? "x" : "y"` names its tokens in the source. The compiler resolves every one of them, with diagnostics, and hands the element the resolved props alongside the tests. **Any token carrying a variant prefix.** A variant depends on the live environment, so even a plain static string literal forces the runtime path: ```tsx title="Static string, runtime path" ``` The trigger is the variant, not the dynamism. The cost is an import. **A structural utility.** Margins (except the static `mx-auto`/`my-auto`), `divide-*` and `animate-*` build wrapper frames, separator frames or animation loops. None can be a static prop. The one motion utility that does *not* promote is `transition-*` alone. With nothing to animate, it warns `transition-without-runtime` and is dropped. > **The in-game token resolver understands far less than the compiler** > > It handles the colour, background, radius, padding, margin, gap and sizing prefixes, plus `divide-*`, the case transforms, the decorations and the motion families. Every other utility inside a *text* `className` the host has to parse is dropped with no diagnostic. That covers layout, alignment, text size, position and constraints. > > This limit applies to text and nothing else. Variant-prefixed tokens and the tokens inside a branch > go through the static resolver and are only *evaluated* at runtime, which is why > `big ? "text-lg" : "text-sm"` works while `` `text-${size}` `` silently does not. The full list of divergences is in [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md). ## Five rules that keep you on the static path Almost every "my class did nothing" report comes from an element that quietly took the runtime path. 1. **Write `className` as a plain string literal.** A literal inside a `.map()` callback is still a literal — what matters is the expression in the attribute. 2. **When the look changes between known states, write the branch out**, as `[base, on ? "bg-blue-600" : "bg-slate-700"]`, rather than interpolating a computed string. That keeps both looks resolved and checked. Branching the whole JSX element additionally avoids the host. 3. **When a number changes continuously, set the property directly**, as `Size={UDim2.fromScale(health, 1)}`, and let the classes carry what does not change. Never set the same property from both a class and a prop. On a collision, Vela emits after you and the class wins. 4. **Give every element an explicit size.** Roblox starts instances at zero, so a missing `h-*` is an invisible element, not a small one. 5. **Prefer `gap-*` to margins.** Margins build a wrapper frame, which forces the runtime path even in a plain literal — and they sum with the parent's gap. Variants are the deliberate exception: `hover:` on a control, a breakpoint on a handful of top-level containers. What does not earn the host is an *opaque* string written where a readable one would do. The runtime resolver lowers everything the static path does, so what you lose is not the classes but every check on them. ## Next step Get the honest boundaries of the project in [Scope and status](https://docs.astra-void.xyz/vela-rbxts/getting-started/scope-and-status.md). --- # Scope and status > What Vela covers at 0.13.0, which Tailwind families are permanently out, and where enforcement has gaps. Source: https://docs.astra-void.xyz/vela-rbxts/getting-started/scope-and-status/ Vela borrows Tailwind's syntax, not its scope. What it ships is a Roblox-oriented mapping. Utilities that correspond to real Roblox properties and helper instances, plus a few structural emulations where the correspondence can be built honestly. Margins become a wrapper frame, and `divide-*` becomes separator frames. Everything that cannot be expressed is rejected loudly. This page maps where those edges are at **0.13.0**. ## What is in scope Utilities apply to exactly eight host elements: `frame`, `scrollingframe`, `canvasgroup`, `textlabel`, `textbutton`, `textbox`, `imagelabel`, `imagebutton` They also apply to **components** — any tag starting with an uppercase letter, or any member expression such as ``. A `className` there lowers into props and helper children on the component itself, which reaches the screen only if the component forwards what it does not consume. See [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#classname-on-components). Anything that is neither is skipped, keeps its `className` verbatim, and reports `classname-on-unsupported-host`. That covers lowercase intrinsics Vela does not implement, such as `screengui`, and namespaced tags such as ``, which never count as components. Within those eight the covered ground is wide. Paint: colours (arbitrary hex and `/N` opacity modifiers included), gradients, borders, rings and outlines, per-corner radius, shadows and opacity. Space. Padding, margins, gap and `space-*`, `divide-*` separators, sizing with min and max constraints, position on all four edges, anchor point, translation, rotation, scale, aspect ratio, z-index and layout order. Layout: flex layout and flex items, grid with real cell sizing, visibility, overflow and the ScrollingFrame families. Then typography, image scaling, interactivity and overscroll, and arbitrary length values across most of it. Finally a `TweenService`-backed motion layer with the runtime variants — `hover:`, `active:`, `focus:`, `dark:`, the breakpoints and their `max-` complements, and the attribute-backed states you define yourself. `opacity-*` is the one utility that reaches past the element it is written on ([how far, and where it stops](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#effects)). A config can add utilities through [plugins](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#plugins), and the [utilities reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md) enumerates all of it. Two things cut across all of it. Every pixel offset a utility lowers is a [rem unit](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem) following the viewport. And the whole set emits for [Vide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) as well as React, with the same utilities, the same diagnostics, and a byte-identical static emit. ## What is permanently out Vela distinguishes a class it has never heard of, reported as `unsupported-utility-family`, from a real Tailwind family Roblox UI cannot express, reported as `no-roblox-equivalent`. The second list is a design decision, not a roadmap. Letter spacing, per-element filters, 3D transforms, the CSS positioning model, grid spans, cursors, text indentation, and the rest of the [not-implemented table](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#not-implemented). Each entry names what Roblox is missing. ## The gaps that do not announce themselves Most of Vela's edges produce a diagnostic. The ones below do not, and they are worth reading twice. > **Four silent gaps** > > **Host-element restrictions are editor-only on the static path.** Vela knows which utilities make sense on which tag. That table drives completions, hover and the `unsupported-host-utility` diagnostic in the language server. **The static transform never consults it**, so `` compiles clean and emits `TextColor3` on a Frame. Install the [editor tooling](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md) to catch it. The runtime path *does* apply the rule, but by dropping the utility without saying so. > > **Components that drop what they are handed.** If the component does not forward its unrecognized > props to a host element and render `children`, all of it vanishes. There is no warning in the editor > or the build. > > **A class value the compiler cannot read is unchecked.** Where the final string is only known in > game, nothing warns about anything in it. `bg-blu-600` is a diagnostic in a literal, and total > silence in `` `flex > ${tone}` ``. Coverage is not the problem. The in-game resolver handles every family the static path does, and branches whose tokens are written out resolve at compile time with their diagnostics intact. What is left is genuinely opaque text. The theme is re-parsed from serialized text there too, so a value not shaped like `Color3.fromRGB(r, > g, b)` or `new UDim(a, b)` degrades silently. > [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) covers the divergence. > > **Gradient stops set to `transparent`.** They produce no diagnostic — the stop is dropped, and a two-stop gradient renders as a flat fill. Every other family without a transparency channel does warn. ## One behavior worth knowing before you write anything > **Preflight neutralizes the Roblox host defaults** > > Roblox paints every `GuiObject` as an opaque grey box with a 1px border, and a framework that only *adds* properties can never take that back. So a supported host element **carrying a `className`** starts from `BackgroundTransparency = 1` and `BorderSizePixel = 0` instead. > > Anything that paints opts back out: a `bg-*`, `opacity-*`, a gradient stop, or a `BackgroundColor3` > / `BackgroundTransparency` prop of your own. A background painted by a variant reopens it at > runtime, so `hover:bg-sky-500` still works on an otherwise transparent element. An element with no > `className` is untouched, and so is a **component**. > > Anywhere the grey default was load-bearing, the element renders invisible. [`preflight: false`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight) restores the old behaviour wholesale. ## Sharp edges still open `gap-*` on a `grid` element emits a conflicting `UIListLayout` alongside the `UIGridLayout` it feeds ([utilities reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#grid)). And `divide-*` separators remain sensitive to explicit `LayoutOrder` and still double-count the parent's `gap-*`, a consequence of separators being list items ([utilities reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#divide)). Three newer edges sit beside them. The static path emits `` in lowercase, which `Instance.new` rejects. A plugin utility that reaches back into itself expands to nothing, without a diagnostic. And an `opacity-*` arriving from an ancestor component composes over a `BackgroundTransparency` you set as a prop. All five are tracked in the [release notes](https://docs.astra-void.xyz/vela-rbxts/reference/release-notes.md#still-open). ## Release state Every package in the repo is at **0.13.0**, versioned in lockstep: `vela-rbxts` itself, the `@vela-rbxts/*` scope, the three `@rbxts/vela-runtime*` packages, and the `vela-rbxts-lsp` VS Code extension. A fix anywhere moves everything. `@rbxts/vela-runtime-core` holds the target-neutral half: the resolution engine, theme normalization, rem maths, rich text, and margin and divide computation. `@rbxts/vela-runtime` and `@rbxts/vela-runtime-vide` are the React and Vide hosts over it. Rojo maps the whole `node_modules/@rbxts` directory into a place, so keeping the hosts apart is what stops a Vide game from shipping React. Almost nothing is enforced at install time. The only `peerDependencies` are each host's own UI library, declared **optional**, and the only `engines` field is the VS Code extension's `vscode: ^1.91.0`. A mismatched roblox-ts or TypeScript fails at build rather than at install. Prebuilt native binaries also miss two platforms: Windows on ARM and linux arm64 musl. ## A performance note Config discovery runs for **every eligible source file** — the walk up the tree, and a read of whatever config it lands on. That is what lets two directories carry different configs. The expensive part is not per file: the loaded config is keyed on the file's own text and resolved once per directory per build. Keep `vela.config.ts` to a plain `defineConfig` call with literal values — no imports beyond `vela-rbxts`, no computed theme generation, no side effects. It is executed as real code, and any edit invalidates the cache. A JSON config skips the transpile step and is cached the same way. ## See also - [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md) for the pipeline, the two lowering paths, and the rules that keep you on the good one. - [Release notes](https://docs.astra-void.xyz/vela-rbxts/reference/release-notes.md) for what changed in each version. - [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) for every warning code and what triggers it. - [Troubleshooting](https://docs.astra-void.xyz/vela-rbxts/guides/troubleshooting.md) for the symptom-first version of this page. - [Editor setup](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md) for the checks the compiler does not run. --- # Editor setup > The VS Code extension, what the language server provides, and wiring the LSP into any editor. Source: https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup/ Vela ships a language server, `vela-rbxts-lsp`, and a VS Code extension that drives it. The server reuses the compiler crate directly, so what the editor tells you about a class name is what the compiler would do with it. The same parser, the same theme resolution, the same diagnostic codes. ## VS Code Install **Vela LSP** from the marketplace, id `astra-void.vela-rbxts-lsp`. It requires VS Code `^1.91.0`. It activates on TypeScript and TypeScript React documents with a `file:` scheme. There is nothing to enable per project — open a `.tsx` file and the server starts. ### Settings The extension contributes three settings and **no commands** — changing any of them restarts the client for you. | Prop | Type | Description | | --- | --- | --- | | `velaRbxts.lsp.serverPath` | `string` | Explicit path to a server binary. A relative path is resolved against the workspace root. Defaults to "", meaning auto-resolve. | | `velaRbxts.lsp.trace.server` | `"off" \| "messages" \| "verbose"` | LSP protocol trace level written to the extension's output channel. Defaults to "off". | | `velaRbxts.lsp.enabled` | `boolean` | Whether to start the language client at all. Defaults to true. | | `velaRbxts.inlayHints.enabled` | `boolean` | Show what each class lowers to as an inlay hint after the class. Added in 0.13.0. Defaults to false. | ### How the server binary is resolved `velaRbxts.lsp.serverPath`, if set, is used directly (resolved against the workspace root when relative). Otherwise the extension launches the server through `process.execPath` with `require.resolve("@vela-rbxts/lsp")`, gated on the matching platform package being present. > **Two platforms have no prebuilt binary** > > The VSIX bundles the same platforms npm publishes: **darwin** arm64 and x64, **linux x64** gnu and musl, **linux arm64 gnu**, and **win32 x64**. Linux arm64 musl and Windows on ARM have no binary on either channel — build the server from source and point `velaRbxts.lsp.serverPath` at the result. There is no `cargo run` fallback. Developing the server itself, build it and set `serverPath` explicitly. ## What the language server provides The declared capability set is small and deliberate. **Completions.** Offered only when the compiler reports the cursor inside a `className` context, on the trigger characters `-`, `:`, `"`, `'` and space. There is no resolve step, so every item arrives complete, and a completion rewrites the segment under the cursor and nothing else. `placeholder-transparent` is not offered on a `textbox`, which the compiler turns down. Every other family taking the `transparent` keyword still offers it. > **A class value the compiler has to walk into counts as that context** > > Every capability on this page reads the class strings out of the shapes a `className` is really written in. Chief among them a **function**, which is how a [Vide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) project writes a dynamic class value. Also a template's interpolations, `as const`, `satisfies`, string concatenation, and an object's computed keys and spreads. **Hover.** Shows what a class token resolves to. A viewport-scaled offset reads as its rem value first — `` Sets `UIPadding` to `1rem` (16px at the base viewport). `` — in hovers and completion docs alike. A config that [pins rem](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#pinning-offsets-back-to-literal-pixels) gets plain pixel wording, matching the emit it produces. **Diagnostics.** Push-based via `publish_diagnostics`, debounced 200 ms and gated on the document version. There is no pull-diagnostics provider. **Document colors and color presentations.** Color utilities get a swatch in the gutter and can be edited through the editor's color picker. **Code actions.** Quickfixes driven by diagnostics whose source is `vela-rbxts`, each offering up to three ranked replacements plus a "remove token" action. There is also the **source action** `source.sortVelaClasses` below. **Document highlight.** Highlights other occurrences of the token under the cursor. **Inlay hints, since 0.13.0.** The editor can show what each class lowers to, after the class: ```tsx // ↑ UIPadding.PaddingTop/Right/Bottom/Left // ↑ UICorner.TopLeftRadius, BottomLeftRadius ``` They are **off by default**, behind `velaRbxts.inlayHints.enabled`. The labels come from the compiler's own lowering, read back through an editor API. That is the complete list. The server does **not** provide go-to-definition, references, rename, formatting, semantic tokens or signature help. Your editor falls back to its TypeScript language service for them. ### Sorting class names The compiler exposes a canonical class order, offered as the source action `source.sortVelaClasses`. It rewrites every `className` in the document at once — layout, sizing, spacing, colours, radius, typography, with variant-prefixed tokens last: ```tsx title="before" ``` ```tsx title="after" ``` **The sort never changes what your classes do.** Utilities that can write the same Roblox property are treated as one group and keep their relative order within it. A `px-4` written after `p-2` stays after it. A template keeps the whitespace around each interpolation. **It does not change how they are laid out either.** Whitespace between tokens is carried over as written. A class list broken across several lines stays that way. An arbitrary payload containing a space, such as `w-[calc(100% - 4px)]`, moves as the single class it is. A value whose bracket never closes is left alone entirely. Run it from the editor's source-action menu, or on save: ```json title=".vscode/settings.json" { "editor.codeActionsOnSave": { "source.sortVelaClasses": "explicit" } } ``` Any LSP client supporting source actions can invoke it by that kind. The underlying API is `sortClassNames` on `@vela-rbxts/compiler`. It takes `{ source, fileName }` and returns `{ edits: [{ range: { start, end }, text }] }`. Those are the same offsets the server turns into workspace edits. Text synchronization is incremental and the position encoding is UTF-16. > **unsupported-host-utility is editor-only** > > The rule that `text-*` belongs on `textlabel`/`textbutton`/`textbox`, `image-*` on `imagelabel`/`imagebutton` and `placeholder-*` on `textbox` is enforced **only** in the editor. `` shows a squiggle in VS Code and compiles without complaint, emitting `TextColor3` onto a Frame. Treat it as a lint. > > Inside a component's `className` the rule is dropped rather than merely unenforced. The editor cannot know which host element `` renders, so it completes and hovers the full utility set there and never raises `unsupported-host-utility`. The editor also suppresses `unknown-theme-key` while the payload typed so far is still a prefix of a real key. `bg-sla` never flashes a warning on the way to `bg-slate-800`, while `bg-nope` warns immediately. > **A config the extension cannot read now says so** > > A `vela.config.ts` the extension fails to load raises a notification naming the file and the reason, and a config that loads on a later save clears it. Without that the session stays silently on the default theme, reporting every key the project defines as unknown. ## Other editors The server speaks LSP over stdio and has no VS Code dependency. Spawn it with: ```bash npx --package @vela-rbxts/lsp vela-rbxts-lsp ``` The binary is named `vela-rbxts-lsp` but owned by the `@vela-rbxts/lsp` package. `vela-rbxts-lsp` on its own is the VS Code extension id, published as a VSIX, so a bare `npx vela-rbxts-lsp` resolves nothing. **The server does not read `vela.config.ts` from disk.** It has no file loader and no config discovery. The client evaluates each config and hands over the result. Without that, the server falls back to the built-in default theme and every custom key reports `unknown-theme-key`. Pass the configs in `initializationOptions`: ```json title="initializationOptions" { "workspaceRoot": "/abs/path/to/project", "configs": [ { "dir": "/abs/path/to/project", "json": "{\"theme\":{\"colors\":{ }}}" } ] } ``` `workspaceRoot` and `configs` are top-level siblings. Get the shape wrong and there is no error. The server deserializes with `from_value(...).ok().unwrap_or_default()`, so anything unparseable becomes zero configs and you are back on the default theme. Each entry is a directory plus the resolved config serialized as JSON. The server matches a file to the nearest containing `dir`, which is how per-package configs in a monorepo work. To update configs after startup, send the custom method `vela-rbxts/setConfigs`. Its payload carries only `configs`. That is what the VS Code extension does. It watches `**/vela.config.{ts,json}`, evaluates each match through `@vela-rbxts/rbxtsc-host/project-config`, and pushes `{ dir, json }` pairs at startup and on every change. A client that cannot evaluate TypeScript can build the same JSON any way it likes — the server only cares that the shape matches the resolved `TailwindConfig`. ### Platform packages The `vela-rbxts-lsp` launcher resolves one of six platform-specific packages: - `@vela-rbxts/lsp-darwin-arm64` - `@vela-rbxts/lsp-darwin-x64` - `@vela-rbxts/lsp-linux-arm64-gnu` - `@vela-rbxts/lsp-linux-x64-gnu` - `@vela-rbxts/lsp-linux-x64-musl` - `@vela-rbxts/lsp-win32-x64-msvc` Those six are what the release workflow publishes and what the VSIX bundles. The launcher's lookup table also names `@vela-rbxts/lsp-linux-arm64-musl` and `@vela-rbxts/lsp-win32-arm64-msvc`, but nothing builds them — on those two, build from source. ## See also - [Configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md) — the config shape the server expects. - [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) — every code the editor can surface, and which ones also appear in a build. - [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) — the case the editor cannot help you with, because nothing is reported at all. --- # 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' ``` 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 } }); ``` 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`. --- # Layout and sizing > How the layout, sizing, position and transform utilities lower to Roblox layout instances. Source: https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing/ Roblox has no box model. An element is positioned by `Position` and `AnchorPoint`, sized by `Size` and `AutomaticSize`, and laid out by child instances like `UIListLayout` and `UIPadding`. Vela maps onto those instances where it can, emulates where it can build one honestly, and warns where neither is possible. Two rules explain most of the surprises here. A utility sets a property on the element, or on a *helper instance* Vela prepends to its children. And several Tailwind names that look like siblings land on different Roblox properties, because Roblox splits alignment and flex distribution into separate enums. ## Flex and `UIListLayout` Any flex, alignment, gap, or wrap utility contributes to a single `UIListLayout` child. Vela emits one per element and merges every contributing utility into it. Direction comes from `flex`, `flex-row`, and `flex-col`. Bare `flex` and `flex-row` both mean horizontal: | Class | Property | | --- | --- | | `flex` | `FillDirection = Enum.FillDirection.Horizontal` | | `flex-row` | `FillDirection = Enum.FillDirection.Horizontal` | | `flex-col` | `FillDirection = Enum.FillDirection.Vertical` | `flex-row-reverse` and `flex-col-reverse` produce `unsupported-flex-direction` — `UIListLayout` has no reversed fill direction. Reorder the children, or invert their `order-*` values. ### `justify-*` splits across two properties `justify-start`, `justify-center` and `justify-end` set `HorizontalAlignment`. The distribution values `justify-between`, `justify-around` and `justify-evenly` set **`HorizontalFlex`**, a different property with a different enum type. | Class | Property | Value | | --- | --- | --- | | `justify-start` | `HorizontalAlignment` | `Enum.HorizontalAlignment.Left` | | `justify-center` | `HorizontalAlignment` | `Enum.HorizontalAlignment.Center` | | `justify-end` | `HorizontalAlignment` | `Enum.HorizontalAlignment.Right` | | `justify-between` | `HorizontalFlex` | `Enum.UIFlexAlignment.SpaceBetween` | | `justify-around` | `HorizontalFlex` | `Enum.UIFlexAlignment.SpaceAround` | | `justify-evenly` | `HorizontalFlex` | `Enum.UIFlexAlignment.SpaceEvenly` | > **They do not override each other** > > They are separate properties, so `justify-center justify-between` sets both and the two interact inside Roblox's layout solver rather than the later class winning. Pick one. `items-*` splits the same way on the cross axis. `items-start`, `items-center` and `items-end` set `VerticalAlignment`, while `items-stretch` sets **`VerticalFlex = Enum.UIFlexAlignment.Fill`**. The `content-*` family drives the same two properties: `content-start`, `content-center` and `content-end` set `VerticalAlignment`, and the rest set `VerticalFlex`. These names are axis-fixed, not direction-relative: `justify-*` always drives the horizontal properties and `items-*` the vertical, even under `flex-col`. ### Wrapping and gap `flex-wrap` and `flex-nowrap` set `UIListLayout.Wraps` to `true` and `false`. `gap-{key}` sets `UIListLayout.Padding`. There is no `gap-x-` or `gap-y-` — `UIListLayout` has a single `Padding` applying along the fill direction. `space-x-{key}` and `space-y-{key}` set the same `Padding` and the matching `FillDirection` in one token, so `space-y-2` is exactly `flex flex-col gap-2`. ```tsx title="src/client/Toolbar.tsx" ``` _Interactive preview: Nested lists: a column of two rows, each with its own UIListLayout._ The example keeps to the alignment half of `justify-*` — the distribution values lower to `UIFlexAlignment`, which the renderer behind these previews does not implement. ### Flex items and `UIFlexItem` These lower to a `UIFlexItem` child on the element itself. The mapping is not one-to-one with CSS — several names collapse onto the same `FlexMode`. | Class | `UIFlexItem` property | | --- | --- | | `flex-1` | `FlexMode = Enum.UIFlexMode.Fill` | | `flex-auto` | `FlexMode = Enum.UIFlexMode.Fill` | | `flex-initial` | `FlexMode = Enum.UIFlexMode.Shrink` | | `flex-none` | `FlexMode = Enum.UIFlexMode.None` | | `grow` | `FlexMode = Enum.UIFlexMode.Grow` | | `grow-0` | `FlexMode = Enum.UIFlexMode.None` | | `shrink` | `FlexMode = Enum.UIFlexMode.Shrink` | | `shrink-0` | `FlexMode = Enum.UIFlexMode.None` | | `self-auto`, `self-start`, `self-center`, `self-end`, `self-stretch` | `ItemLineAlignment` | Numeric variants such as `grow-2` or `flex-2` are not recognized. `basis-{value}` exists but is currently a synonym for `w-{value}` — see the [sizing note](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#size) in the reference. ### Child order `order-{n}` sets `LayoutOrder`, which is what `UIListLayout` sorts by. Negative forms (`-order-2`) and the keywords `order-first` (−9999), `order-last` (9999) and `order-none` (0) are accepted. Reach for it where CSS would use `flex-row-reverse`. ## Grid and `UIGridLayout` `grid` creates a `UIGridLayout` child with `SortOrder = LayoutOrder`. `grid-cols-{n}` (1–12) sets `FillDirection = Horizontal` with `FillDirectionMaxCells = n`, and `grid-rows-{n}` does the same vertically. There are no spans: `col-span-*` and `row-span-*` have no `UIGridLayout` counterpart and report `no-roblox-equivalent`. **The grid is the one layout that sizes its own children.** `UIGridLayout` stamps `CellSize` onto every child, so a `w-*` on a cell does nothing. The track count decides the width: ```tsx title="grid grid-cols-2 gap-2.5" ``` The cross axis stays at Roblox's 100px default until you name it with `auto-rows-*` (or `auto-cols-*` under `grid-rows-*`). Both read the spacing scale, so `auto-rows-24` is a 96px row. > **Keep gap-* off grid elements for now** > > `gap-*` on a `grid` element feeds `UIGridLayout.CellPadding`, but it also emits an ordinary `UIListLayout` carrying the same value, leaving two layouts fighting over the children. Set `CellPadding` as a prop and leave `gap-*` off. ## Padding and `UIPadding` Padding utilities collect into one `UIPadding` child. The axis mapping is direct: | Class | Properties set | | --- | --- | | `p-*` | `PaddingTop`, `PaddingRight`, `PaddingBottom`, `PaddingLeft` | | `px-*` | `PaddingLeft`, `PaddingRight` | | `py-*` | `PaddingTop`, `PaddingBottom` | | `pt-*` | `PaddingTop` | | `pr-*` | `PaddingRight` | | `pb-*` | `PaddingBottom` | | `pl-*` | `PaddingLeft` | ## Margins are built, not native Roblox has no margin box, so Vela constructs one. A positive margin wraps the element in a transparent frame padded by the margin values, with the element's layout-facing props routed onto the wrapper. That wrapper cannot be a static prop, so **any margin utility moves the element onto the [runtime path](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md)**, even in a plain string literal. The variants: | Class | Effect | | --- | --- | | `m-*`, `mx-*`, `my-*`, `mt-*`, `mr-*`, `mb-*`, `ml-*` | margin box (runtime path) | | `-mt-*`, `-ml-*` | `Position` shift — negative top/left margins move instead of wrapping | | `-mr-*`, `-mb-*` | `unsupported-negative-margin` | | `mx-auto`, `my-auto` | **static** centering: `AnchorPoint` 0.5 + `Position` scale 0.5, no wrapper | The wrapper participates in the parent's list layout, so margins **sum with the parent's `gap-*`** — a `gap-2` column whose children carry `my-2` shows 16-pixel gaps. Save margins for what gap cannot express, like asymmetric spacing around a single child. ## The spacing value grammar `p-*`, `m-*`, `gap-*`, the min/max constraints and the offset branch of the sizing utilities share one value resolver, in two steps. 1. **Theme lookup.** The key is looked up in `theme.spacing`. If it is there, that roblox-ts expression string is used verbatim. 2. **Arithmetic fallback.** If the key is not in the theme, it is parsed as a number. It must be finite, non-negative, and an exact multiple of `0.5`. The result is `new UDim(0, key * 4)`. So `p-1.5` is `new UDim(0, 6)` and `p-40` is `new UDim(0, 160)`. `p-0.25` fails both steps and emits `unknown-theme-key`. A leading sign is rejected outright. > **The built-in spacing scale has one key** > > `theme.spacing` ships with exactly `"4"`. Every other numeric key works through the arithmetic fallback. Adding named keys is covered in [Theming](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md). > **Since 0.12.0 those offsets are rem, not pixels** > > `new UDim(0, 160)` is what `w-40` is worth **at the base viewport**. Every offset on this page is a rem unit carried as `__VelaRem.scale(…)`, following the player's viewport and clamped into `[min, max]`. These numbers hold at 1920×1020, and everywhere if you [pin the clamp](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#pinning-offsets-back-to-literal-pixels). Scale-valued utilities are untouched. ## Sizing and `Size` `w-*`, `h-*`, and `size-*` build a `UDim2` for the element's `Size` property. Each axis value resolves through one of four branches. **`px`** is offset `1` — a literal one-pixel axis, not a unit suffix. **`full`** is scale `1`. **Fractions** map to a scale. The accepted set is exact and not arithmetic: | Denominator | Accepted numerators | | --- | --- | | `/2` | `1` | | `/3` | `1`, `2` | | `/4` | `1`, `3` | | `/5` | `1`, `2`, `3`, `4` | | `/6` | `1`, `5` | | `/12` | `1` through `11` | Anything outside that table is rejected, including the reducible forms. `w-2/4` and `w-3/6` are **not** accepted even though they equal `w-1/2`. Write `w-1/2`. **Anything else** goes through the spacing grammar above and becomes an offset. A resolved spacing value with a non-zero scale component, such as a custom theme entry of `new UDim(0.5, 0)`, gives `unsupported-size-spacing-value`. ### Automatic sizing `auto` and `fit` are handled separately and lower to `AutomaticSize` rather than `Size`: | Class | Property | | --- | --- | | `w-fit`, `w-auto` | `AutomaticSize = Enum.AutomaticSize.X` | | `h-fit`, `h-auto` | `AutomaticSize = Enum.AutomaticSize.Y` | | `size-fit`, `size-auto` | `AutomaticSize = Enum.AutomaticSize.XY` | | `w-fit h-fit` | `AutomaticSize = Enum.AutomaticSize.XY` | ### What gets emitted The two axes merge into a single `Size` prop, and the emitted expression depends on which components are zero: - Both scales zero → `UDim2.fromOffset(x, y)` - Both offsets zero → `UDim2.fromScale(x, y)` - Otherwise → `UDim2.new(sx, ox, sy, oy)` ```tsx title="Merged into one Size" // Size = UDim2.new(1, 0, 0, 48) // Size = UDim2.fromScale(0.5, 0.5) // Size = UDim2.fromOffset(160, 32) ``` `w-` and `h-` on the same element always merge into one `Size`, on both lowering paths — `md:w-32 md:h-32` keeps both axes. See [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md). ## Min and max size `min-w-`, `max-w-`, `min-h-`, and `max-h-` build a `UISizeConstraint` child with `MinSize` and `MaxSize` as `Vector2` values. These values are **offset-only** — they go through the spacing offset resolver, so `max-w-1/2` does not work. An unspecified min axis defaults to `0` and an unspecified max axis to `math.huge`, so `max-w-80` alone emits `MaxSize = new Vector2(320, math.huge)`. ## Position and anchor Position utilities write into the element's `Position`: | Class | Effect | | --- | --- | | `left-*` | sets the X component | | `top-*` | sets the Y component | | `right-*` | sets the X component, measured from the far edge | | `bottom-*` | sets the Y component, measured from the far edge | | `inset-*` | sets both components | | `-left-*`, `-top-*`, `-inset-*` | the same, negated | They accept `px`, `full`, the same exact fraction set as sizing, and spacing offsets. `right-*` and `bottom-*` express the coordinate from the far edge, so `right-2` emits `new UDim(1, -8)` on X. They do **not** change `AnchorPoint`, so the point they place is still the element's top-left corner. Pair them with `origin-*`: ```tsx title="8px inside the parent's bottom-right corner" ``` Vela has no `absolute` / `relative` / `fixed` / `static` family. Every Roblox GUI object is already positioned relative to its parent, so those keywords report `no-roblox-equivalent`. A parent with a `UIListLayout` owns its children's positions, so `left-*` and `top-*` will not take effect there. `origin-{key}` sets `AnchorPoint`. Exactly nine keys are valid: | Class | `AnchorPoint` | | --- | --- | | `origin-top-left` | `(0, 0)` | | `origin-top` | `(0.5, 0)` | | `origin-top-right` | `(1, 0)` | | `origin-left` | `(0, 0.5)` | | `origin-center` | `(0.5, 0.5)` | | `origin-right` | `(1, 0.5)` | | `origin-bottom-left` | `(0, 1)` | | `origin-bottom` | `(0.5, 1)` | | `origin-bottom-right` | `(1, 1)` | Anything else is `unsupported-anchor-value`. ### Translation `translate-x-*` and `translate-y-*` shift an element the way CSS transforms do, splitting by value kind. A **fraction** is a shift measured in the element's own size, which is what `AnchorPoint` expresses. A **pixel** value adds to the `Position` offset. So the CSS centring idiom works verbatim: ```tsx title="Dead center — AnchorPoint (0.5, 0.5), Position (0.5, 0.5) scale" ``` `mx-auto` and `my-auto` compile to the same centring, one axis at a time. Because fractional translation *is* `AnchorPoint`, do not combine it with `origin-*` on the same element — the later token wins. ## Aspect ratio `aspect-{key}` emits a `UIAspectRatioConstraint` child with an `AspectRatio` value. `aspect-square` is `1` and `aspect-video` is `1.7777777778`. Two arbitrary forms are also accepted: `aspect-[W/H]`, which divides, and `aspect-[N]`, a single number. Both require positive finite values. ```tsx title="Arbitrary aspect ratios" ``` `aspect-auto` is not supported and emits `unsupported-aspect-value`. ## Z-index `z-{value}` sets `ZIndex`, and only six values are accepted: `z-0`, `z-10`, `z-20`, `z-30`, `z-40`, `z-50`. Everything else produces one of four distinct diagnostics, so the message tells you exactly what went wrong: | Input | Diagnostic | | --- | --- | | `z-auto` | `unsupported-z-index-auto` | | `-z-10` | `unsupported-negative-z-index` | | `z-[7]` | `unsupported-arbitrary-z-index` | | `z-5` | `unsupported-z-index-value` | ## Rotation and scale `rotate-N` and `-rotate-N` set `Rotation` in degrees. The accepted degree set is exact: `0`, `1`, `2`, `3`, `6`, `12`, `45`, `90`, `180`. Anything else is `unsupported-rotation-value`. `-rotate-0` resolves to `0`. `scale-N` emits a `UIScale` child. The value map is fixed: | Class | `Scale` | | --- | --- | | `scale-0` | `0` | | `scale-50` | `0.5` | | `scale-75` | `0.75` | | `scale-90` | `0.9` | | `scale-95` | `0.95` | | `scale-100` | `1` | | `scale-105` | `1.05` | | `scale-110` | `1.1` | | `scale-125` | `1.25` | | `scale-150` | `1.5` | Any other value is `unsupported-scale-value`, and scaling is uniform — there is no `scale-x-*`/`scale-y-*`. ## Visibility and clipping `hidden` sets `Visible = false` and `visible` sets `Visible = true`. `overflow-hidden` and `overflow-clip` both set `ClipsDescendants = true`. `overflow-visible` sets it to `false`. Any other value emits `unsupported-overflow-value`, since scrolling belongs to the `scrollingframe` host element rather than to a style. ## What layout still cannot express - **Grid spans and flow**: `col-span-*`, `row-span-*`, `grid-flow-*`. `UIGridLayout` has no span concept, so these report `no-roblox-equivalent`. - **The CSS positioning model**: `absolute`, `relative`, `fixed`, `sticky`, `float`, `clear`. Roblox positioning is always parent-relative. - **Axis gaps**: `gap-x-*` and `gap-y-*`. They match the `gap-` prefix, so the leftover text is read as a spacing key and the failure comes out as `unknown-theme-key` rather than an unknown-family warning. - **Reversed fill**: `flex-row-reverse`, `flex-col-reverse`. Use `order-*`. ## See also - [Colors and surfaces](https://docs.astra-void.xyz/vela-rbxts/guides/colors-and-surfaces.md) for `bg-*`, `border-*`, gradients, and shadows. - [Responsive and input variants](https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants.md) for breakpoint- and input-conditional layout. - [Recipes](https://docs.astra-void.xyz/vela-rbxts/guides/recipes.md) for these families composed into real interface pieces. - [Utilities reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md) for the complete class list. - [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) for every warning code named on this page. --- # Colors and surfaces > Colour utilities and the Roblox properties they write — fills, strokes, radius, shadows, gradients, opacity. Source: https://docs.astra-void.xyz/vela-rbxts/guides/colors-and-surfaces/ Every colour utility resolves a key from `theme.colors` and writes it onto a specific Roblox property. That is either the host instance or a helper Vela adds as a child. Nothing is inherited and nothing cascades: a class writes one property on one instance. ```tsx title="src/client/Card.tsx" ``` _Interactive preview: A fill, a fill with a stroke, a pill radius, and a gradient — four utility families, four different targets._ Open the **Lowered** tab on that preview to see the split the table below describes. `bg-*` lands on the frame, while `border-*`, `rounded-*` and the gradient stops each become a child instance. ## The color families | Family | Roblox property | Written on | | --- | --- | --- | | `bg-*` | `BackgroundColor3` | the host instance | | `text-*` | `TextColor3` | the host instance | | `image-*` | `ImageColor3` | the host instance | | `placeholder-*` | `PlaceholderColor3` | the host instance | | `border-{color}`, `ring-{color}`, `outline-{color}` | `Color` | the shared `UIStroke` child | | `shadow-{color}` | `Color` | a `UIShadow` child | | `divide-{color}` | `BackgroundColor3` | the separator frames | | `from-*`, `via-*`, `to-*` | color keypoints | a `UIGradient` child | Each of these except `placeholder-*`, `divide-*` and the gradient stops has a paired transparency property. Setting `border-{color}` writes `Color` *and* sets `Transparency` to `0`, so a coloured stroke is visible without a second class. `shadow-{color}` writes `Color` alone. `text-*` is not only a color family. It also carries sizes, alignment, and wrapping — see [Text and fonts](https://docs.astra-void.xyz/vela-rbxts/guides/text-and-fonts.md) for how the compiler decides which one you meant. ### Arbitrary hex colors Every colour family also accepts a bracketed hex payload, bypassing the theme: three- and six-digit forms lower straight to `Color3.fromRGB(...)`, and anything else reports `unsupported-arbitrary-value`. Reach for these sparingly — a hex that appears twice is a theme key waiting to be named in [theming](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md). ### Opacity modifiers A color can carry a level in one token: `/N` with an integer 0–100 sets the family's transparency alongside its color, Tailwind-style. `bg-blue-600/50` emits `BackgroundColor3` plus `BackgroundTransparency = 0.5`. `text-slate-100/80` fades text to 80% opacity. It works on every family Roblox gives a transparency channel, which is all of them but one. `border-slate-500/25` sets the `UIStroke`'s `Transparency`, and `divide-white/10` fades the separator frames. `from-blue-600/50` becomes a `UIGradient.Transparency` keypoint aligned with its colour stop, so fading one stop leaves the others alone. `placeholder-*` is the one family left. Roblox has no placeholder transparency, and fading the text itself would take the typed value with it, so it still reports `unsupported-opacity-modifier`. ## `transparent`, and the two rejected keywords `transparent` is not a theme color. It is handled specially: it sets the family's **transparency** property to `1` and **removes** the color property entirely. `bg-transparent` emits `BackgroundTransparency = 1` and no `BackgroundColor3`. > **You rarely need bg-transparent any more** > > [Preflight](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight) already starts every classed host element at `BackgroundTransparency = 1`, so `bg-transparent` on a label or layout frame is a no-op. It still earns its place on an element whose background is painted by a variant and should be clear by default, and anywhere you set `preflight: false`. `text-transparent`, `image-transparent` and `border-transparent` are unaffected. That is why `placeholder-transparent` is an error. `PlaceholderColor3` has no paired transparency property, so there is nothing for the utility to set. The compiler reports `unsupported-color-key` rather than emitting something that would not work. Gradient stops are the other family with no transparency property, and they produce **no diagnostic at all**. The stop is dropped and the gradient is built from what remains, so a two-stop gradient that quietly becomes one renders as a flat fill. `current` and `inherit` are rejected outright with `unsupported-color-key`. Both are cascade concepts, and there is no cascade on Roblox — a `TextLabel` does not learn its colour from an ancestor `Frame`. ## Shades and diagnostics A colour payload is either a literal key or a palette key plus a shade. The suffix after the last hyphen counts as a shade only if it is one of the eleven numbers. Anything else makes the whole payload one semantic key, so `bg-my-color` looks up `my-color`. A bare palette name resolves through the palette's `DEFAULT`, and every built-in palette ships one mirroring its `500`. `DEFAULT` is a config key and never a class — `bg-blue-DEFAULT` is read as the semantic key `blue-DEFAULT` and reported as `unknown-theme-key`. Three diagnostics cover the failure modes: | Code | Fires when | | --- | --- | | `color-missing-shade` | you referenced a palette with no shade and that palette defines no `DEFAULT` | | `color-invalid-shade` | you gave a literal a shade (`bg-white-500`), or asked a palette for a shade it does not define | | `unknown-theme-key` | the name is not in `theme.colors` at all | All three are warnings, not errors. The class is dropped and compilation continues. See [Theming](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md) for what the default palette contains and how to add your own. ## Borders `border-*` builds a `UIStroke` child. Roblox strokes are a much narrower surface than CSS borders, and Vela's border family reflects that narrowness rather than papering over it. Thickness accepts **exactly four values**: `border-0`, `border-1`, `border-2`, and `border-4`. Bare `border` is shorthand for thickness `1`. Any other number, such as `border-3` or `border-8`, is rejected with `unsupported-border-value`. `border-transparent` sets `Transparency = 1`. `border-{color}` sets `Color` and `Transparency = 0`. `border-round`, `border-bevel`, and `border-miter` set `LineJoinMode` to `Enum.LineJoinMode.Round`, `.Bevel`, and `.Miter`. This family is easy to miss because it has no CSS analogue. ```tsx title="src/client/Panel.tsx" ``` Everything below is explicitly detected and rejected with `unsupported-border-value`, so you get a diagnostic instead of a silent no-op: - Border styles — `border-dashed`, `border-solid`, `border-dotted`, `border-double`. `UIStroke` draws one continuous line and has no style property. - Side-specific keys — `border-x`, `border-y`, `border-t`, `border-r`, `border-b`, `border-l`, and their prefixed forms like `border-t-2` or `border-x-slate-500`. A `UIStroke` outlines the whole instance. There are no per-side strokes. (For dividers *between* children, `divide-x`/`divide-y` builds real separator frames — see the [utilities reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#divide).) - `border-opacity-*`. Use the `/N` modifier, as in `border-slate-500/25`, or `border-transparent`. An arbitrary bracket thickness is *not* rejected: `border-[3]`, `border-[3px]` and `border-[0.125rem]` all reach `UIStroke.Thickness`, and like every other offset they scale with the viewport. ### Rings and outlines `ring` and `outline` exist for Tailwind muscle memory. `ring` is thickness 3, `outline` thickness 2, and both set `ApplyStrokeMode = Border`. But Roblox gives one reliable `UIStroke` per instance, so all three families **write into the same helper** and the later token wins. There is no CSS-style ring stacked on a border. Off-list values are `unsupported-stroke-value`. ## Radius `rounded-{key}` sets `CornerRadius` on a `UICorner` child, and on the static path it is a **pure theme lookup**. The key must exist in `theme.radius` or you get `unknown-theme-key` on the `radius` family. There are no arbitrary values on the static path: `rounded-[8]` reports `unsupported-arbitrary-value` and emits nothing. If you need a radius the theme does not have, add it to `theme.extend.radius` and give it a name. ```ts title="vela.config.ts" theme: { extend: { radius: { card: "new UDim(0, 10)" } } } ``` ```tsx ``` ## Shadows `shadow` and the size presets build a `UIShadow` child. The presets are fixed. There is no way to tune them from a class. | Class | Blur radius | Y offset | Spread | Transparency | | --- | --- | --- | --- | --- | | `shadow` | 3 | 1 | 0 | 0.9 | | `shadow-sm` | 2 | 1 | 0 | 0.95 | | `shadow-md` | 6 | 4 | −1 | 0.9 | | `shadow-lg` | 15 | 10 | −3 | 0.9 | | `shadow-xl` | 25 | 20 | −5 | 0.9 | | `shadow-2xl` | 50 | 25 | −12 | 0.75 | Blur becomes `BlurRadius`, offset becomes `Offset`, and spread becomes `Spread` — written only when non-zero, which is why `shadow` and `shadow-sm` do not set it. `shadow-none` sets `Enabled = false` on the `UIShadow`, disabling it rather than removing the instance. `shadow-inner` is rejected with `unsupported-shadow-inset`. `UIShadow` draws outside the instance and has no inset mode. `shadow-{color}` is a separate family that sets only the shadow's `Color`, leaving whatever `Transparency` the preset established. The one exception is `shadow-transparent`, which writes `Transparency = 1` and no color. It composes with a preset: ```tsx ``` ## Gradients `bg-gradient-to-{dir}` creates a `UIGradient` child. `bg-linear-to-{dir}` is an accepted alias for the same thing. Eight directions map to `UIGradient` rotations: | Class suffix | Direction | Rotation | | --- | --- | --- | | `t` | to top | 270 | | `tr` | to top right | 315 | | `r` | to right | 0 | | `br` | to bottom right | 45 | | `b` | to bottom | 90 | | `bl` | to bottom left | 135 | | `l` | to left | 180 | | `tl` | to top left | 225 | Rotation is only emitted when it is not `0`, so `bg-gradient-to-r` leaves the property at its default. A direction outside the eight reports `unsupported-gradient-direction`. Color stops come from `from-*`, `via-*`, and `to-*`, and how many you write changes the emitted `ColorSequence`: - One stop becomes `new ColorSequence(c)` — a flat sequence of a single color. - Two stops become `new ColorSequence(a, b)`. - Three stops become `new ColorSequence([keypoints])` with explicit keypoints. ```tsx title="src/client/Header.tsx" ``` _Interactive preview: Two stops, three stops with via-*, and a vertical direction — each swatch is one UIGradient child._ > **A gradient forces the background to white** > > With a gradient present, Vela sets `BackgroundColor3` to white, because `UIGradient` multiplies against the instance's background and anything else would tint every stop. So a `bg-*` on the same element is overwritten — pick the gradient or the fill, not both. ## Element opacity `opacity-N` accepts **any integer from 0 to 100**, not a fixed step scale, and inverts it. Roblox measures transparency where CSS measures opacity. `opacity-100` is fully opaque, `opacity-0` is fully transparent, and `opacity-40` emits `0.6`. A non-integer or out-of-range value reports `unsupported-opacity-value`. The editor's completion list shows a shorter set of round numbers, but that list is suggestion sugar — `opacity-37` compiles fine. **It fades everything the element draws, and everything under it.** The value reaches every transparency channel the host paints. It is then handed down the subtree as a running product, `1 - (1 - own) * alpha`. That descent crosses component boundaries too, travelling as a React context through `{props.children}`. Reach for the `/N` modifier when you want a *single* family faded — `text-slate-100/80` fades the text and leaves the surface alone. The two multiply rather than fight: `opacity-50 bg-blue-600/50` lands at 0.75 transparency, in either order. > **A `canvasgroup` is the real composite** > > `opacity-*` on a `canvasgroup` lowers to `GroupTransparency`, compositing the subtree in one pass and ending the descent there. Everywhere else Vela fades each instance individually, which is visibly different when siblings overlap — the overlap darkens. Wrap them in a `canvasgroup` when that shows. ## See also - [Theming](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md) — the color, radius, and spacing scales these utilities read. - [Text and fonts](https://docs.astra-void.xyz/vela-rbxts/guides/text-and-fonts.md) — how `text-*` disambiguates between color, size, alignment, and wrapping. - [Utilities reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md) — the complete family list. - [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) — every code, and what triggers it. --- # Text and fonts > Text sizing, weight, alignment, wrapping and truncation — plus the host restriction the compiler does not actually enforce. Source: https://docs.astra-void.xyz/vela-rbxts/guides/text-and-fonts/ Text utilities write to the text properties of Roblox's text instances: `TextSize`, `TextColor3`, `FontFace`, `TextXAlignment`, `TextYAlignment`, `TextWrapped`, `TextTruncate` and `LineHeight`. Two of them rewrite the `Text` string itself. Three host elements have those properties: `textlabel`, `textbutton`, and `textbox`. ```tsx title="src/client/Title.tsx" ``` _Interactive preview: Three labels: size, weight, color and alignment, all from the text-* and font-* families._ > **Weights are approximated in the preview** > > The **Lowered** tab shows what Vela really emits for `font-*`: a `FontFace` carrying a `Font` value built from the theme's family, the weight, and the style. The browser renderer behind these previews has no `Font` datatype, so the preview maps each weight onto the nearest legacy `Font` enum member instead. Relative weights read correctly. The exact face does not. ## The host restriction is editor-only Vela knows which utilities belong on which host element, and reports `unsupported-host-utility` when you get it wrong. That check lives in the LSP surface — diagnostics, hover, completions, and document colors. **The compiler never consults it.** The transform pipeline does not check host tags at all. `` compiles cleanly and emits `TextColor3` onto a `Frame`, with no build-time warning. > **Editor warns, build does not** > > The utility-per-host rules are enforced **only** by the editor and LSP. Text utilities belong on `textlabel`, `textbutton` and `textbox`, `image-*` on `imagelabel` and `imagebutton`, and `placeholder-*` on `textbox`. `rbxtsc` will happily emit `TextColor3` on a `Frame`. > > Roblox ignores an unknown property assignment, so nothing crashes — the class does nothing and you get no signal outside the editor. Use [editor setup](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md) to get the warning. ### The restriction does not apply to components Inside a component's `className` the rule does not exist. Vela has no idea which host element `` renders, so the editor completes and hovers the full set, `text-*`, `image-*` and `placeholder-*` included. It never reports `unsupported-host-utility`. ```tsx title="No warning anywhere, in the editor or the build" <Title className="text-2xl font-semibold" Text="Match found" /> ``` Whether that `text-2xl` reaches a `TextSize` depends on your component forwarding what it does not consume down to a text host. See [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#classname-on-components). ## How `text-*` is disambiguated `text-` is the most overloaded prefix in the whole system. One rule, applied in order, decides what a given `text-*` class means: 1. If the payload is a text-size key (`xs` through `9xl`), it is `TextSize`. 2. If the payload is `left`, `center`, `right`, or `justify`, it is `TextXAlignment`. 3. If the payload is `wrap` or `nowrap`, it is `TextWrap`. 4. **Everything else falls through to `TextColor`.** The fourth step is a catch-all, so there is no such thing as an unrecognised `text-*` class. `text-foo` is read as a colour, `foo` is not in `theme.colors`, and you get **`unknown-theme-key`** rather than `unsupported-utility-family`. So read `unknown-theme-key` on a `text-*` class as "you probably meant a size or an alignment". ## Size `text-{size}` sets `TextSize`, in rem — the numbers below are what each key is worth at the base viewport. See [rem](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem). | Class | `TextSize` | | --- | --- | | `text-xs` | 12 | | `text-sm` | 14 | | `text-base` | 16 | | `text-lg` | 18 | | `text-xl` | 20 | | `text-2xl` | 24 | | `text-3xl` | 30 | | `text-4xl` | 36 | | `text-5xl` | 48 | | `text-6xl` | 60 | | `text-7xl` | 72 | | `text-8xl` | 96 | | `text-9xl` | 128 | The set is fixed and does not come from the theme. Anything outside the list falls through to the colour branch above. > **A scaled TextSize stops at 100** > > Roblox stops honouring `TextSize` past 100 and does it silently, so rem stops there too: on a large viewport `text-6xl` and up land on that ceiling. > > The ceiling belongs to the scaling, so it covers arbitrary values too: `text-[240px]` emits `__VelaRem.scaleText(240, 0)` and renders at 100. [Pin the clamp](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#pinning-offsets-back-to-literal-pixels) and the scaling leaves the emit entirely. ## Weight, family and style `font-*` is one prefix over three axes. It resolves the fixed weight names first and reads anything else as a **font family key**, so weight, family and style merge into a single `FontFace`. ```tsx title="font-mono font-bold italic" FontFace={new Font("rbxasset://fonts/families/RobotoMono.json", Enum.FontWeight.Bold, Enum.FontStyle.Italic)} ``` ### Weight `font-{weight}` sets the weight axis of that `Font` value: ```lua new Font("rbxasset://fonts/families/SourceSansPro.json", Enum.FontWeight.SemiBold) ``` | Class | `Enum.FontWeight` | | --- | --- | | `font-thin` | `Thin` | | `font-extralight` | `ExtraLight` | | `font-light` | `Light` | | `font-normal` | `Regular` | | `font-medium` | `Medium` | | `font-semibold` | `SemiBold` | | `font-bold` | `Bold` | | `font-extrabold` | `ExtraBold` | | `font-black` | `Heavy` | Two names do not match their class: `font-normal` produces `Regular` and `font-black` produces `Heavy`, following the Roblox enum member names. `italic` rides in the same `FontFace` value as a third argument, so `font-bold italic` emits `new Font("…/SourceSansPro.json", Enum.FontWeight.Bold, Enum.FontStyle.Italic)`. `not-italic` resets the style axis. ### Family The family is a theme axis: `theme.fontFamily` ships three keys, and `font-{key}` selects one: | Class | Family | | --- | --- | | `font-sans` | Source Sans Pro — the default when no family class is present | | `font-serif` | Merriweather | | `font-mono` | Roboto Mono | Add your own the way you add any other theme key. The value is a Roblox font family asset path, not an expression: ```ts title="vela.config.ts" export default defineConfig({ theme: { extend: { fontFamily: { display: "rbxassetid://12345678", body: "rbxasset://fonts/families/Nunito.json", }, }, }, }); ``` ```tsx <textlabel className="font-display font-bold" Text="Match found" /> ``` Because the family lookup is the fallback branch, a payload that is neither a weight nor a configured family key reports **`unknown-theme-key`**. > **Setting `FontFace` directly still works** > > A font you do not want in the theme can be set as a prop, leaving `font-*` off that element: > > ```tsx > <textlabel > FontFace={new Font("rbxasset://fonts/families/GothamSSm.json", Enum.FontWeight.Bold)} > Text="Custom family" > /> > ``` > > It does not compose with `font-bold` or `italic`, which emit a whole `FontFace` of their own. ## Alignment Horizontal alignment comes from `text-left`, `text-center`, and `text-right`, which set `TextXAlignment`. Vertical alignment uses a different prefix: `align-top`, `align-middle` and `align-bottom` set `TextYAlignment` to `Top`, `Center` and `Bottom`. Anything else after `align-` reports `unsupported-text-alignment`. `text-justify` parses but does not resolve, since `TextXAlignment` has no justified mode. It reports `unsupported-text-alignment`. ## Line height `leading-{key}` sets `LineHeight` from six named keys — `none` (1), `tight` (1.25), `snug` (1.375), `normal` (1.5), `relaxed` (1.625), `loose` (2). The numeric Tailwind forms (`leading-5`, `leading-[1.2]`) are not accepted and report `unsupported-line-height-value`. ## Wrapping and truncation `text-wrap` sets `TextWrapped = true`, and `text-nowrap` sets it to `false`. The `whitespace-normal` and `whitespace-nowrap` pair is an accepted alias for the same property. Any other `whitespace-*` value is `unsupported-whitespace-value`. `truncate` sets `TextTruncate = Enum.TextTruncate.AtEnd`. It takes no payload, and `AtEnd` is the only truncation mode Vela emits. ```tsx title="src/client/PlayerRow.tsx" <textlabel className="text-sm text-nowrap truncate" Text={playerName} /> ``` ## Case transforms rewrite the string There is no text-transform property, so `uppercase`, `lowercase`, `capitalize` and `normal-case` work on the `Text` string itself. When `Text` is a literal the rewrite happens **at compile time**: ```tsx title="In" <textlabel className="uppercase" Text="match found" /> ``` ```tsx title="Out" <textlabel Text="MATCH FOUND" /> ``` When `Text` is an expression, the transform has to run in-game. The element moves onto the [runtime path](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md), and the helper transforms the value as it changes. ## Decorations use RichText `underline` and `line-through` wrap the text in RichText markup, emitting `Text="<u>hello</u>"` with `RichText={true}` and escaping the content as needed. `no-underline` removes them. If the element sets `RichText` itself, Vela backs off with `decoration-on-richtext`. `overline` has no RichText equivalent and reports `no-roblox-equivalent`. ## Placeholders `placeholder-*` sets `PlaceholderColor3` and is meaningful only on `textbox`. As with every host restriction on this page, the editor flags it and the compiler does not. `placeholder-transparent` is an error (`unsupported-color-key`) — `PlaceholderColor3` has no paired transparency property. See [Colours and surfaces](https://docs.astra-void.xyz/vela-rbxts/guides/colors-and-surfaces.md) for the other families. ## Typography families that cannot exist A few Tailwind typography families have no Roblox property to target, and report `no-roblox-equivalent` rather than reading as typos: - `tracking-*` — letter spacing. The Roblox text engine exposes nothing for it. - `indent-*`, `break-*`, `hyphens-*`, `list-*` — no indentation, line-breaking, or list-marker control. - `decoration-*` and `overline` — RichText has underline and strikethrough only. ## See also - [Colors and surfaces](https://docs.astra-void.xyz/vela-rbxts/guides/colors-and-surfaces.md) — the color half of `text-*`, plus `transparent` and the shade rules. - [Theming](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md) — where `text-{color}` keys come from. - [Editor setup](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md) — getting the host-restriction warnings the compiler does not give you. - [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) — every code and what triggers it. --- # Responsive and input variants > Every variant prefix, how each condition is detected, and what using one costs. Source: https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants/ A variant is a condition evaluated at runtime on the player's client. A token only applies when its condition matches. | Variant | Matches when | | --- | --- | | `sm` `md` `lg` `xl` `2xl` | viewport width is at least 640, 768, 1024, 1280, 1536 | | `max-sm` … `max-2xl` | viewport width is below that same threshold | | `attr-[Name=value]` | the styled instance carries that Roblox attribute | | `portrait` | viewport width is less than viewport height | | `landscape` | viewport width is greater than or equal to viewport height | | `touch` | the active input mode is touch | | `mouse` | the active input mode is mouse | | `gamepad` | the active input mode is gamepad | | `hover` | the pointer is over this element | | `active` | this element is being pressed | | `focus` | this element holds focus or selection | | `dark` | the local player carries `VelaColorScheme = "dark"` | You write a variant as a colon prefix on a utility, exactly as in Tailwind: ```tsx title="src/client/Panel.tsx" <frame className="w-full md:w-1/2 lg:w-1/3" /> ``` Prefixes chain, and a chained token requires **all** of its conditions to match: ```tsx title="Only on a touch device in portrait" <frame className="hidden touch:portrait:visible" /> ``` `active:`, `focus:` and `dark:` arrived in 0.7.0. The `max-` forms and `attr-[…]` arrived in 0.13.0. There is still no `not-` form, no `disabled:`, and no group or peer variants. Order within the chain does not matter — the conditions are combined as a set. An unrecognized prefix reports `unknown-variant`, whose message lists the supported set. The token is dropped. ## Breakpoints, and their complements Breakpoints are pixel thresholds on the viewport's X dimension, and **since 0.13.0 they are a theme axis** rather than a hard-coded three: | Variant | Applies when | Complement | | --- | --- | --- | | `sm:` | `width >= 640` | `max-sm:` — `width < 640` | | `md:` | `width >= 768` | `max-md:` — `width < 768` | | `lg:` | `width >= 1024` | `max-lg:` — `width < 1024` | | `xl:` | `width >= 1280` | `max-xl:` — `width < 1280` | | `2xl:` | `width >= 1536` | `max-2xl:` — `width < 1536` | A `max-` form is the **exact** complement of the bare one. The minimum is inclusive and the maximum is not, so at exactly 768px `md:` applies and `max-md:` does not. Between them the two cover every viewport once. They chain, which is how you address one bucket and nothing else: ```tsx title="Only between 768 and 1024" <frame className="md:max-lg:w-1/2" /> ``` A chain whose bounds leave no viewport, such as `lg:max-md:` or `md:max-md:`, reports `invalid-breakpoint-range` rather than compiling to a rule that never fires. A `max-` in front of something that is not a breakpoint reports `unknown-breakpoint` and names the ones that are. Rename or add thresholds through [`theme.screens`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#screens). One entry defines both prefixes. As in Tailwind, write the small-viewport value unprefixed and layer larger viewports on top. The helper tracks the camera's `ViewportSize` signal, so a breakpoint re-evaluates when the viewport changes rather than being sampled once at mount. (It was sampled once in `0.4.0`, which broke every breakpoint in that release — see the [release notes](https://docs.astra-void.xyz/vela-rbxts/reference/release-notes.md#041) if you are pinned to it.) ## How orientation is derived Orientation is not read from a Roblox API. It is computed from the camera's `ViewportSize`: if width is greater than or equal to height the result is `landscape`, otherwise `portrait`. The `>=` matters. A perfectly square viewport counts as **landscape**, not portrait. If you are branching on orientation for a layout that has a meaningful square case, test it explicitly rather than assuming portrait catches it. ## How input mode is detected The input mode is read from `UserInputService` and resolved by priority, not by which device was used most recently: 1. If `GamepadEnabled` is true → `gamepad` 2. Otherwise if `TouchEnabled` is true → `touch` 3. Otherwise → `mouse` Exactly one mode is active at a time. On a device that reports both a gamepad and a touchscreen, `gamepad:` matches and `touch:` does not — gamepad wins over touch, and touch wins over mouse. A player who plugs in a controller mid-session flips to `gamepad` because the helper subscribes to the `TouchEnabled`, `MouseEnabled`, and `GamepadEnabled` change signals. ## Hover, active and focus are per-element Three variants have conditions that live on the element rather than in the environment. In each case the runtime helper attaches its listeners **only when the element carries a rule for that variant**. It composes with any handlers you wrote yourself rather than replacing them. | Variant | Tracked through | Notes | | --- | --- | --- | | `hover` | `MouseEnter` / `MouseLeave` | | | `active` | `InputBegan` / `InputEnded` | Mouse and touch; also clears on `MouseLeave` | | `focus` | `Focused` / `FocusLost` on a `textbox`, `SelectionGained` / `SelectionLost` elsewhere | | `active:` clears on `MouseLeave`, because a release outside the element never reaches it. Without that, dragging off a pressed button would leave it stuck. `focus:` splits by host because Roblox does: a `textbox` has real keyboard focus, while every other element only has gamepad/controller selection. Both map to the same variant. These are where the [motion utilities](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#motion) earn their keep: a `transition` on the same element tweens the properties a rule changes instead of snapping them. ```tsx title="A button with all three" <textbutton className="w-28 h-10 rounded-md bg-sky-500 hover:bg-sky-600 active:bg-sky-700 focus:ring-2 transition duration-150" Text="Play" /> ``` Remember that a gamepad or touch player may never hover. Treat `hover:` styling as feedback, never as the only signal for something important — `focus:` is what a controller player actually gets. ## Dark mode is state your app owns Roblox exposes no color scheme to a running game, so `dark:` cannot read one. It matches when **`Players.LocalPlayer` carries a `VelaColorScheme` attribute set to `"dark"`**. The runtime host follows that attribute's change signal, so flipping it restyles every `dark:` element immediately. ```tsx title="Somewhere your settings UI can reach" Players.LocalPlayer.SetAttribute("VelaColorScheme", prefersDark ? "dark" : "light"); ``` ```tsx title="And then, anywhere" <frame className="bg-white dark:bg-zinc-900" /> ``` Anything other than `"dark"` is treated as light, including the attribute being absent. The local player is the one instance every element can reach without a provider, so that is where the flag lives. ## State your UI owns `hover:` is a state Roblox exposes. The states a UI has of its own have no fixed list for Vela to guess at. A panel that is open, a row that is selected, a tier a player reached. **You name them.** Both forms read a Roblox attribute off the styled instance: ```tsx title="Inline, where a registration would be ceremony" <frame className="rounded-sm attr-[State=open]:rounded-lg" /> ``` ```ts title="vela.config.ts — or register a name for it" plugin(({ addVariant }) => { addVariant("open", { attribute: "State", equals: "open" }); }); ``` ```tsx title="…and then" <frame className="rounded-sm open:rounded-lg" /> ``` The attribute is the one the rest of the game already reads. It replicates from the server, survives a rejoin, and shows up in Studio's property panel, so the styling layer holds no second copy of the state. Set it the way you set any other: ```tsx <frame ref={(f) => f?.SetAttribute("State", isOpen ? "open" : "closed")} /> ``` `equals` takes a string, number or boolean. Both forms compose with every other variant, and both are checked, completed, hovered and sorted like a built-in one. Neither sends the utility behind them to the in-game parser. `open:rounded-lg` lowers `rounded-lg` exactly as a bare `rounded-lg` does, and only the condition travels. An `attr-[…]` that does not parse reports `malformed-attribute-variant` and names what is missing. ## The cost: any variant forces the runtime path A variant condition cannot be evaluated at compile time, so any token carrying a variant prefix switches that element onto the **runtime path**. The JSX tag becomes a `VelaRuntimeHost`, the variant tokens are serialized into `__velaRules`, the original tag travels as `__velaTag`, and the static props are emitted as casts. > **A variant in a plain string literal is enough** > > `className="sm:w-full"`, a fully static string literal, is enough. One variant-prefixed token anywhere in the file pulls the runtime host into that file's output. What it costs the file is an import. The host lives in `@rbxts/vela-runtime`, one ModuleScript the whole place shares. A file that needs it carries a `createVelaRuntimeHost(...)` call and the config it hands over. That is a few hundred bytes. It is a few thousand for a file whose host must parse a class value and therefore keeps the full theme tables. The host imports `{ Players, TweenService, UserInputService, Workspace }` from `@rbxts/services`, normalizes your theme's colour and spacing strings, and installs subscriptions on each element that uses it. Two consequences: - **Per-element work.** Every runtime-path element re-resolves its tokens whenever the environment changes. - **Theme values are re-parsed from text**, but only where the host must parse a class value. A value that is valid roblox-ts but not shaped like `Color3.fromRGB(r, g, b)` or `new UDim(a, b)` degrades silently in game — see [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md#what-the-runtime-path-still-costs). A variant alone does not put you here: its rules are resolved by the compiler, so a variant-only file ships its scales emptied. Output size, diagnostics and coverage are not on that list. Variant-prefixed tokens are analyzed statically, and the runtime path resolves every utility family the static path does. Use variants where they earn their keep. A handful of top-level containers that reflow between phone and desktop, an input hint that only makes sense on gamepad, hover feedback on interactive controls. Keep leaf components on plain static classes. ## Driving reflow yourself Variants are not the only way to reflow, and sometimes not the best one. When you would rather not pull the runtime host into a file at all, read the viewport yourself and branch between two static class strings. Branching the whole element keeps both looks on the static path, with no host and the full utility set: ```tsx title="src/client/ResponsiveShell.tsx" function useViewportWide(threshold: number) { const [wide, setWide] = React.useState(false); React.useEffect(() => { const camera = Workspace.CurrentCamera; if (!camera) return; const update = () => setWide(camera.ViewportSize.X >= threshold); const connection = camera.GetPropertyChangedSignal("ViewportSize").Connect(update); update(); return () => connection.Disconnect(); }, [threshold]); return wide; } export function Shell(props: { children?: React.Element }) { const wide = useViewportWide(768); return wide ? ( <frame className="flex gap-4 p-4 w-full h-full">{props.children}</frame> ) : ( <frame className="flex flex-col gap-2 p-2 w-full h-full">{props.children}</frame> ); } ``` ## See also - [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) — the other way onto the runtime path, and what that path costs you. - [Utilities reference: Motion](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#motion). Transitions and animations that pair with variants. - [Layout and sizing](https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing.md) — the utilities you will most often want to vary by breakpoint. - [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) — the full warning code list. --- # Dynamic class names > Computing `className` loses no utilities. What a genuinely unreadable string costs. Source: https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names/ `className` is typed as `ClassValue`, so you can compute it: template strings, ternaries, arrays, values from props or state. What changes is *how* the string is lowered. Collapsible to a fixed set of tokens, it takes the **static path** and lowers straight to props. Otherwise the element falls onto the **runtime path**, where the host does the same work in-game. ## The two paths resolve the same utilities Every utility family the [reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md) documents resolves on both paths with the same semantics. That covers opacity modifiers, arbitrary values, and the families that only meet at the end. One asymmetry favours the runtime path. **A utility the host element cannot carry is dropped rather than written**, because writing `TextColor3` onto a `Frame` is a hard Roblox error. The static path warns and writes it anyway. `opacity-*` is the one family where the paths hand off. The compiler leaves the whole class list to the host, which resolves it and hands the children one alpha. See [`opacity-*`](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#effects). ## A branch lowers at compile time An expression that writes its classes out is resolved by the compiler even though it cannot collapse to one class list: `active ? "text-lg" : "text-sm"` names every token it can ever apply. ```tsx title="Both sizes reach the instance" // `text-lg` has no runtime resolution at all. Written as a branch it lowers here. <textlabel className={big ? "text-lg" : "text-sm"} /> ``` Four consequences, all of them the point: - **The full utility set applies inside a branch**, not the runtime resolver's narrower prefix list. - **A bad utility in a branch reports a diagnostic** instead of vanishing. - **A variant inside a branch answers to both** — a `hover:` in a branch applies as `hover:` *and* the branch's test, combined as one condition. - **Each test is evaluated exactly once**, however many branches hang on it. It reads ternaries, `&&`, the literal behind `||`, arrays and object maps, and resolves a branch among the tokens around it. So `["w-40", tall && "h-10"]` composes into one `Size`. The element still renders through the runtime host, because something has to read the tests, but nothing is *parsed* in-game. ```tsx title="What the emit carries" <VelaRuntimeHost __velaRules={[ { condition: { kind: "test", index: 0, expected: true }, effects: { props: [{ name: "TextSize", value: "18" }] } }, { condition: { kind: "test", index: 0, expected: false }, effects: { props: [{ name: "TextSize", value: "14" }] } }, ]} __velaTests={[big ? true : false]} __velaTag={"textlabel"} /> ``` ### What sends a branch back to the runtime resolver Three things, and the whole class value goes — not just the offending branch: - **A token no rule can carry** — `m-*`, `divide-*`, `animate-*`, `transition*`, the text transforms and `opacity-*`. The host reads these off its own props rather than off the resolution. - **A value the source never names** — `` `size-${n}` ``, a bare variable, a call, a spread. - **The left side of `||`**, which is the class value itself when truthy. The literal behind it is still resolved here. Two ordering rules. Branches touching the same property apply in written order, so `[a && "bg-red-500", b && "bg-blue-500"]` paints blue when both hold. Branches touching different halves of one property are **not** merged: `[a && "w-40", b && "h-10"]` is two writes to `Size` and the later wins. ## What the runtime path still costs This is about a class value the compiler cannot read at all — `cn(...)`, a template string, a prop passed through. **No diagnostics.** The compiler never sees the final string. A typo like `bg-blu-600` is an `unknown-theme-key` warning in a literal or a branch, and complete silence here. The token resolves to nothing, and the element renders without a background. **No editor support for the part nothing names.** Completions, hover and swatches work from the literal text, including the literal text *inside* an expression the compiler cannot fold. A token an interpolation *cuts into*, as in `` `w-[${width}]` ``, is left alone. One that merely sits beside an interpolation is checked normally. **Theme values are re-parsed from text.** The static path splices your theme's roblox-ts expression into the output. A class value the host has to parse carries the theme as string data instead. 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 in game**. See [configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#values-are-roblox-ts-expression-strings). Code size is *not* on this list. `@rbxts/vela-runtime` is one ModuleScript the whole place shares, so a runtime-path module carries an import and its config rather than a copy: ```ts title="What a runtime-path module carries" import { createVelaRuntimeHost } from "@rbxts/vela-runtime"; const VelaRuntimeHost = createVelaRuntimeHost({ /* … */ }); ``` ## Three degrees, not two "Static or runtime" is the shape of the emit. What decides how much checking you get is a third axis — whether the compiler could *read* the tokens: | What you wrote | Emitted as | Resolved by | Checked | |---|---|---|---| | `className="flex gap-2"`, or an array of literals | Your tag, plain props | The compiler | Yes | | A variant, a branch, a margin, a `divide-*` | `VelaRuntimeHost` with `__velaRules` | The compiler; the host only reads conditions | Yes | | A string the compiler cannot read | `VelaRuntimeHost` with `className` | The host, in-game | No | Only the third row has a cost worth weighing. **Any variant prefix** puts an element in the middle row, `className="sm:w-full"` included, and so do the structural utilities. > **An array of literals is still fully static** > > `className={["flex", "gap-2"]}` collapses to one class list and stays in the first row — there is no > condition in it to read. A ternary does not collapse, but it lands in the *middle* row rather than > the last: both branches are resolved and checked at compile time. ## How to tell which row an element landed in There is no diagnostic and no flag, so check the emitted roblox-ts. A first-row element keeps its tag and gains plain props plus helper children. Anything else renders as `VelaRuntimeHost` with `__velaTag`: `__velaRules` with `__velaTests` means the compiler resolved everything, while a surviving `className` prop means the string is parsed in-game. ## Writing for the checked rows Reach for tokens the compiler can read wherever losing the typo check would hurt, and compute freely elsewhere. ### Branch the classes, not the element A template string is not a branch the compiler can read. Write it as one: ```tsx title="Read, resolved and checked" <frame className={["flex flex-col items-center gap-2 p-4 rounded-lg", emphasized ? "bg-slate-700" : "bg-slate-800"]} /> ``` Duplicating the whole element still produces the leanest emit, with each branch a complete literal on the static path and no host at all. But it buys only that, not checking. Spend it where an element is hot enough that the difference shows. ### Or use a variant instead of computing If the thing driving the class is something Vela can observe, a variant expresses it without any computation. That covers pointer, press, focus, viewport, input device and colour scheme: ```tsx title="No computed string needed" <textbutton className="bg-slate-800 hover:bg-slate-700 active:bg-slate-600 transition" Text="Play" /> ``` ### Computing is fine for the rest A computed `className` for state your app owns is a normal thing to write, and every token in it resolves: ```tsx title="Fine — resolves fully, just without compile-time checking" const tone = disabled ? "bg-slate-800 text-slate-500" : "bg-blue-600 text-white"; return <frame className={`flex items-center gap-2 p-2 rounded-md ${tone}`} />; ``` The interpolation is what costs the typo check, not the ternary. Hoisting the same ternary into the class value gets it back: ```tsx title="Same rendering, checked" return ( <frame className={[ "flex items-center gap-2 p-2 rounded-md", disabled ? "bg-slate-800 text-slate-500" : "bg-blue-600 text-white", ]} /> ); ``` ## Components take the same rows Everything here applies to `className` on your own components. `<Panel className={classes} />` is lowered exactly like `<frame className={classes} />`, branch resolution included. The one visible difference is the tag. `__velaTag` carries a live reference rather than a string, as `__velaTag={Panel}`, and the host renders your component with the resolved props and helper children. So the component has to forward what it does not consume, or none of it lands — with no diagnostic for getting it wrong. See [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#classname-on-components). A component only emits the modifier-sibling shape on the **static** path. Go dynamic and the `UICorner` is built at runtime instead. ## See also - [The five rules](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#five-rules-that-keep-you-on-the-static-path) — this page compressed to what you have to remember while writing code. - [Responsive and input variants](https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants.md) — the other trigger for the runtime path. - [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md) — the compile pipeline and where the two paths diverge. - [Vide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) — the same three rows under the other target. --- # Vide > Emitting for Vide — no config to write, the same static output, and a thunk for anything dynamic. Source: https://docs.astra-void.xyz/vela-rbxts/guides/vide/ Vela emits for [Vide](https://centau.github.io/vide/) as well as React, and everything this documentation says about utilities, variants, the theme and the two lowering paths holds under either target. **A Vide project needs no Vela config for this.** With `framework` unset, Vela reads the target off the `jsxFactory` a Vide project's `tsconfig.json` already sets: ```json title="tsconfig.json — enough on its own" { "compilerOptions": { "jsx": "react", "jsxFactory": "Vide.jsx", "jsxFragmentFactory": "Vide.Fragment" } } ``` Both runtime hosts ship with `vela-rbxts`, and the one you do not emit for is an inert ModuleScript. The config key exists for saying it out loud rather than having it read — see [naming the target explicitly](#naming-the-target-explicitly). ## The static path is the same output A statically lowered element is *identical* source under both targets. Vide's JSX intrinsics are the same lowercase Roblox class names React's are, and its props are Roblox instance properties directly: ```tsx title="Both targets emit this" <frame BackgroundColor3={Color3.fromRGB(49, 65, 88)} Size={__VelaRem.scale(UDim2.fromOffset(160, 0), 4)}> <uipadding PaddingTop={__VelaRem.scale(new UDim(0, 16), 0)} /> </frame> ``` The whole token-to-utility pipeline is shared: the same parser, theme, diagnostics, helper children and [rem](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem) scaling. Two things differ. The module specifier at the top of the file is `@rbxts/vela-runtime-vide` rather than `@rbxts/vela-runtime`, and the runtime path behaves differently. ## A dynamic class value has to be a thunk The one thing you write differently. A Vide component body runs **once**, so a class value that changes has to be a function, like any other derived Vide prop: ```tsx title="src/client/Button.tsx" const active = source(false); // Tracked — the class list is re-read whenever `active` changes. <textbutton className={() => (active() ? "bg-blue-600" : "bg-slate-700")} /> // Not tracked — read once, at the moment the component body ran. <textbutton className={active() ? "bg-blue-600" : "bg-slate-700"} /> ``` `className` is typed to allow both, as `ClassValue | (() => ClassValue)`, so the second line is not an error. It is a class list that was correct once, and nothing warns about it. The editor reads inside the thunk. Completions, hover, swatches, diagnostics and the class sort all work on a deferred class value the way they do on a literal one. > **A branch still resolves at compile time either way** > > A thunk is about *tracking*, not lowering. `() => (active() ? "bg-blue-600" : "bg-slate-700")` names both tokens in the source. Both are resolved by the compiler, and the element is handed the resolved props alongside the test, exactly as the React form is. Only the test itself is thunked. Both targets resolve the branch into the same two `__velaRules`. What differs is the one line inside `__velaTests`, and the runtime the host is imported from. **Vide** ```tsx title="src/client/Button.tsx" import Vide, { source } from "@rbxts/vide"; export function Button() { const active = source(false); return ( <textbutton className={() => (active() ? "bg-blue-600" : "bg-slate-700")} Text="Ready" /> ); } ``` ```tsx title="Emitted for Vide" import { createVelaRuntimeHost } from "@rbxts/vela-runtime-vide"; import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime-vide"; const VelaRuntimeHost = createVelaRuntimeHost({ "preflight": true, "theme": { "colors": {}, "radius": {}, "spacing": {}, "fontFamily": {}, "screens": {}, "rem": { "base": 16.0, "min": 8.0, "max": 64.0, "baseResolution": { "x": 1920.0, "y": 1020.0 } }, "replaced": [ "colors", "radius", "spacing", "fontFamily", "screens" ] }, "plugins": { "utilities": {} } }) as unknown as VelaRuntimeHostComponent; import Vide, { source } from "@rbxts/vide"; export function Button() { const active = source(false); return (<VelaRuntimeHost Text="Ready" BorderSizePixel={(0 as never)} BackgroundTransparency={(1 as never)} __velaRules={[ { "condition": { "kind": "test", "index": 0, "expected": true }, "effects": { "props": [ { "name": "BackgroundColor3", "value": "Color3.fromRGB(21, 93, 252)" }, { "name": "BackgroundTransparency", "value": "0" } ], "helpers": [] } }, { "condition": { "kind": "test", "index": 0, "expected": false }, "effects": { "props": [ { "name": "BackgroundColor3", "value": "Color3.fromRGB(49, 65, 88)" }, { "name": "BackgroundTransparency", "value": "0" } ], "helpers": [] } } ]} __velaTests={[ ()=>active() ? true : false ]} __velaTag={"textbutton"}/>); } ``` **React** ```tsx title="src/client/Button.tsx" import React, { useState } from "@rbxts/react"; export function Button() { const [active] = useState(false); return ( <textbutton className={active ? "bg-blue-600" : "bg-slate-700"} Text="Ready" /> ); } ``` ```tsx title="Emitted for React" import { createVelaRuntimeHost } from "@rbxts/vela-runtime"; import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime"; const VelaRuntimeHost = createVelaRuntimeHost({ "preflight": true, "theme": { "colors": {}, "radius": {}, "spacing": {}, "fontFamily": {}, "screens": {}, "rem": { "base": 16.0, "min": 8.0, "max": 64.0, "baseResolution": { "x": 1920.0, "y": 1020.0 } }, "replaced": [ "colors", "radius", "spacing", "fontFamily", "screens" ] }, "plugins": { "utilities": {} } }) as unknown as VelaRuntimeHostComponent; import React, { useState } from "@rbxts/react"; export function Button() { const [active] = useState(false); return (<VelaRuntimeHost Text="Ready" BorderSizePixel={(0 as never)} BackgroundTransparency={(1 as never)} __velaRules={[ { "condition": { "kind": "test", "index": 0, "expected": true }, "effects": { "props": [ { "name": "BackgroundColor3", "value": "Color3.fromRGB(21, 93, 252)" }, { "name": "BackgroundTransparency", "value": "0" } ], "helpers": [] } }, { "condition": { "kind": "test", "index": 0, "expected": false }, "effects": { "props": [ { "name": "BackgroundColor3", "value": "Color3.fromRGB(49, 65, 88)" }, { "name": "BackgroundTransparency", "value": "0" } ], "helpers": [] } } ]} __velaTests={[ active ? true : false ]} __velaTag={"textbutton"}/>); } ``` ## What the runtime host does differently Nothing you have to think about, on a host element. The Vide host binds an effect that writes whatever the current resolution names. A property that disappears goes back to what the element declared, or to the class default. Interaction variants, `transition-*` and `animate-*`, the text transforms, `divide-*` and the `opacity-*` model all behave as the React host's do, on the same target-neutral core. The reactive seams underneath are different, and two of their consequences reach you. ### A component element's props are fixed at the call Vide hands a component its props once, rather than writing to an instance it owns. Which props the host can pass down is decided when the component is called: ```tsx title="A prop that only a later reading names cannot appear" <Card className={() => (loud() ? "bg-red-500 rounded-xl" : "bg-slate-700")} /> ``` Both branches are read up front, so both `BackgroundColor3` and the `UICorner` are accounted for. What cannot work is a name no reading at call time produced. A host element has no such limit — it is an instance the host owns. ### A late `m-*` cannot be honoured A margin is a wrapper instance *above* the element, and Vide parents an element as soon as it builds one, so the box cannot be introduced afterwards. A class value naming `m-*` anywhere the compiler can see it emits `__velaMarginBox`, and the host builds the box **before** the element. **Vide** ```tsx title="src/client/Panel.tsx" import Vide from "@rbxts/vide"; export function Panel(p: { big: () => boolean }) { return <frame className={() => (p.big() ? "m-4 p-2" : "p-2")} />; } ``` ```tsx title="Emitted for Vide" import { createVelaRuntimeHost } from "@rbxts/vela-runtime-vide"; import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime-vide"; const VelaRuntimeHost = createVelaRuntimeHost({ "preflight": true, "theme": { "colors": {}, "radius": {}, "spacing": {}, "fontFamily": {}, "screens": {}, "rem": { "base": 16.0, "min": 8.0, "max": 64.0, "baseResolution": { "x": 1920.0, "y": 1020.0 } } }, "plugins": { "utilities": {} } }) as unknown as VelaRuntimeHostComponent; import Vide from "@rbxts/vide"; export function Panel(p: { big: () => boolean; }) { return <VelaRuntimeHost className={()=>p.big() ? "m-4 p-2" : "p-2"} BorderSizePixel={(0 as never)} BackgroundTransparency={(1 as never)} __velaMarginBox={true} __velaTag={"frame"}/>; } ``` **React** ```tsx title="src/client/Panel.tsx" import React from "@rbxts/react"; export function Panel(props: { big: boolean }) { return <frame className={props.big ? "m-4 p-2" : "p-2"} />; } ``` ```tsx title="Emitted for React" import { createVelaRuntimeHost } from "@rbxts/vela-runtime"; import type { VelaRuntimeHostComponent } from "@rbxts/vela-runtime"; const VelaRuntimeHost = createVelaRuntimeHost({ "preflight": true, "theme": { "colors": {}, "radius": {}, "spacing": {}, "fontFamily": {}, "screens": {}, "rem": { "base": 16.0, "min": 8.0, "max": 64.0, "baseResolution": { "x": 1920.0, "y": 1020.0 } } }, "plugins": { "utilities": {} } }) as unknown as VelaRuntimeHostComponent; import React from "@rbxts/react"; export function Panel(props: { big: boolean; }) { return <VelaRuntimeHost className={props.big ? "m-4 p-2" : "p-2"} BorderSizePixel={(0 as never)} BackgroundTransparency={(1 as never)} __velaTag={"frame"}/>; } ``` React emits nothing in that position, because the render that resolves a margin also renders the wrapper around it. What is left is a margin arriving out of an opaque call, where the runtime **warns** rather than rendering the element unspaced. ## Naming the target explicitly The inference is a walk, and it is worth knowing how far it reaches before overriding it. From each source file's directory upward to the nearest `tsconfig.json`, following a **relative** `extends` up to eight levels, reading `compilerOptions.jsxFactory`. A factory beginning with `Vide.` selects Vide. Anything else, or none found, leaves the default of React. Naming the key in `vela.config.ts` always wins, and it wins by being **present** rather than by its value: ```ts title="vela.config.ts — only if you want to override the walk" import { defineConfig } from "vela-rbxts"; export default defineConfig({ framework: "vide" }); ``` So `framework: "react"` pins React even under a Vide `jsxFactory`. And `framework: "vide"` pins Vide where the walk would not have found the factory: a tsconfig reached through a non-relative `extends`, or one nested deeper than the walk goes. Both are escape hatches. A project whose tsconfig says `Vide.` in the ordinary way has no reason to write either. > **One target per project, not per file** > > `jsxFactory` is a program-wide TypeScript setting, so a project pointing it at Vide cannot compile React > JSX at all. The choice is per project by construction. It is also why an unset `framework` is read as *the default* rather than as a positive request for React. A project that never mentioned Vela's config but compiles Vide JSX is asking for Vide. ## Writing the declaration file Unchanged. The same side-effect import augments both namespaces, `React.Attributes` and `Vide.Attributes`, so one file covers whichever you compile for: ```ts title="src/vela-env.d.ts" import "vela-rbxts"; ``` ## See also - [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) — what a branch costs and what it does not, on both targets. - [`framework`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#framework) — the config key itself. - [Release notes](https://docs.astra-void.xyz/vela-rbxts/reference/release-notes.md#0120) — what shipped in the Vide pass. --- # Recipes > Small, complete UI patterns — buttons, badges, meters, list rows, a dialog — compiled and rendered live. Source: https://docs.astra-void.xyz/vela-rbxts/guides/recipes/ The other guides explain Vela one utility family at a time. This page builds the small pieces most Roblox interfaces are made of. Every preview is compiled by the same compiler `rbxtsc` loads: **Classes** for the source, **Lowered** for what comes out. Every example stays on the [static path](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#the-two-lowering-paths). Two conventions repeat throughout, both worth internalizing before you copy anything: - **Everything has an explicit size.** Roblox instances default to zero-height `Size` values, which is why the examples write `w-*` and `h-*` on almost everything. The noise is Roblox's defaults, not Vela's requirements. Backgrounds are not in that bucket any more: since 0.5.0 [preflight](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight) starts every classed element transparent, so a label that should not paint needs no `bg-transparent`. - **Rows and columns are `UIListLayout`.** Any element with `flex`, `gap-*`, `justify-*` or `items-*` on it gets exactly one `UIListLayout` child. That layout owns the positions of every child under it. See [layout and sizing](https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing.md) for the full model. ## Buttons _Interactive preview: Filled, outlined, and destructive — the outline is a UIStroke, and everything else is four utility families._ A button is a `textbutton` with sizing, a radius, a background and text styling. The difference between the filled and outlined variants is one `border border-slate-600` pair, which lowers to a `UIStroke` child. Hover feedback comes straight from the [`hover:` variant](https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants.md#hover-active-and-focus-are-per-element), and pairing it with `transition` tweens the change instead of snapping it: ```tsx title="Hovering tweens the fill over 150ms" <textbutton className="w-28 h-10 rounded-md bg-sky-500 hover:bg-sky-600 transition duration-150 text-white text-sm font-semibold" Text="Play" /> ``` A variant moves the element onto the [runtime path](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md), and a gamepad or touch player may never hover — treat it as feedback, not the only signal. **There is no `active:` or `pressed:` variant.** Press feedback is state you own. Track it with React events and branch between two fully static class strings. Both looks stay on the static path with the full utility set: ```tsx title="src/client/ActionButton.tsx" export function ActionButton(props: { label: string; onClick: () => void }) { const [pressed, setPressed] = React.useState(false); return pressed ? ( <textbutton className="w-28 h-10 rounded-md bg-sky-600 text-white text-sm font-semibold" Text={props.label} Event={{ Activated: props.onClick, MouseButton1Up: () => setPressed(false), MouseButton1Down: () => setPressed(true), }} /> ) : ( <textbutton className="w-28 h-10 rounded-md bg-sky-500 text-white text-sm font-semibold" Text={props.label} Event={{ Activated: props.onClick, MouseButton1Up: () => setPressed(false), MouseButton1Down: () => setPressed(true), }} /> ); } ``` The duplication is the point: one computed string would push the element onto the [runtime path](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md), which resolves the same utilities but checks none of them. **Buttons do not size to their label.** A bare `textbutton` is zero-sized. `w-fit`/`h-fit` lower to `AutomaticSize`, but a fixed `w-28 h-10` is the predictable default for anything sitting in a row with other buttons. ## Badges _Interactive preview: Status pills: rounded-full, a dark shade for the fill, a light shade of the same palette for the text._ A pill is a `textlabel` with `rounded-full` — `new UDim(0.5, 0)`, half the instance's own height, so it stays a capsule at any size. Roblox centres label text by default, so no alignment classes are needed, and the label's own background paints the pill. The colour pattern is worth stealing: fill from the dark end of a palette, border one step lighter, text from the light end. Every built-in palette carries the same eleven shades, so it transfers to any hue — swap `emerald` for `amber`, `rose`, or a palette of your own. ## Stat bars _Interactive preview: A meter is a track and a fill: the fraction is the value, and the fill sits at the track's origin because a plain frame does not lay out its children._ A meter needs no layout instance. The track is a `rounded-full` frame, and the fill a plain child with `w-2/3 h-full`. With no `UIListLayout` on the track, the fill sits at its top-left origin. Fractions lower to the *scale* component of `Size`, so `w-2/3` means two thirds of the track with no pixel maths. To drive the fill from live data, resist the computed class string. `w-*` is in the runtime resolver's subset, but you would pay for the whole [runtime path](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) to express a number. Set `Size` directly and let the classes carry what does not change: ```tsx title="The fraction is data — pass it as a prop" <frame className="w-72 h-2 rounded-full bg-slate-800"> <frame className="rounded-full bg-emerald-500" Size={UDim2.fromScale(health, 1)} /> </frame> ``` One rule makes this safe. **Never set the same property from a class and a prop on one element.** On a collision Vela emits after you, and the class wins. An `h-full` beside that `Size` prop would overwrite it with a `Size = (0, 1)` scale. ## List rows _Interactive preview: A column of rows, each row its own UIListLayout. Column widths are chosen so rank, avatar, name, and score fill the row exactly._ Lists are the pattern Roblox UIs live in: a `flex flex-col gap-2` column whose children are `flex items-center gap-3` rows. Each element gets its own `UIListLayout`, so nesting costs nothing to reason about. The score column is pushed to the right edge with fixed widths. Rank, avatar, name and score plus the gaps add up to the row's inner width. `justify-between` exists and lowers to `UIListLayout.HorizontalFlex`, but the renderer behind these previews does not implement flex distribution. Both are legitimate in a real place, and the fixed-width one is what you need whenever a column must not shrink. In real code the rows come from data, and a `className` written as a literal *inside the callback* is still a static string: ```tsx title="Still the static path" {entries.map((entry, index) => ( <frame className="flex items-center gap-3 w-full h-9 px-3 rounded-md bg-slate-800" key={entry.id}> {/* … */} </frame> ))} ``` What matters is the expression in the attribute, not where it appears. `className={rowClasses}` with a computed `rowClasses` is dynamic. The literal above is not. ## A confirmation dialog _Interactive preview: Column layout, wrapped body text, and a justify-end button row — the alignment half of justify-*, which is plain UIListLayout._ The dialog combines everything above. A column with `gap-3`, a `text-wrap` body, and a `justify-end` row for the buttons that lowers to `HorizontalAlignment = Right`. `TextWrapped` is off by default, so long text clips without it. To present it as a modal, parent the card to a full-screen scrim and lift it above the rest of the interface: ```tsx title="src/client/Modal.tsx" <frame className="size-full bg-slate-950 opacity-60 z-50"> <frame className="origin-center left-1/2 top-1/2 w-96 h-40 …">{/* the card */}</frame> </frame> ``` Three details do the work. `opacity-60` maps to `BackgroundTransparency`, inverted. `origin-center` sets `AnchorPoint` to `(0.5, 0.5)`, so `left-1/2 top-1/2` centres the card rather than placing its corner. And `z-50` raises `ZIndex`. The scrim's transparency does not cascade, so the card keeps its own opaque background. ## When a recipe needs state Every pattern here eventually meets data: a selected tab, a disabled button, a filling meter. The rule is always the same: - **The look changes between known states** → branch between two complete static literals, as the button does. Full utility set, full diagnostics, zero runtime cost. - **A number changes continuously** → keep the classes static and set the property directly, as the meter does, on a property no class on that element touches. - **Only a colour, radius, spacing or size varies** → a computed string is acceptable, since those families are within the [runtime resolver's subset](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md). Know that you are opting into the runtime path for that element. - **The state is the pointer being over the element** → that one is built in: `hover:`, ideally with `transition`. What you should not do is compute a string carrying layout classes — `flex`, `items-center`, alignment, text styling. Those are dropped silently at runtime, and the failure mode is a broken screen with a clean build. See [dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md), or the short version in the [five rules](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#five-rules-that-keep-you-on-the-static-path). ## See also - [Your first component](https://docs.astra-void.xyz/vela-rbxts/getting-started/first-component.md) — the same walkthrough at single-utility resolution. - [Layout and sizing](https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing.md) — the layout model these recipes lean on. - [Troubleshooting](https://docs.astra-void.xyz/vela-rbxts/guides/troubleshooting.md) — when a recipe does not look like its preview. --- # Troubleshooting > A symptom-first map of Vela's failure modes, the silent ones especially. Source: https://docs.astra-void.xyz/vela-rbxts/guides/troubleshooting/ Vela reports most mistakes as [diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md). This page is for the other situation: **something is wrong on screen and the build said nothing.** > **Check your version first** > > Most of the silent failures below were fixed releases ago. Run `npm ls @vela-rbxts/compiler` and read the [release notes](https://docs.astra-void.xyz/vela-rbxts/reference/release-notes.md) first. Upgrading is the whole fix for several problems. Runtime-path coverage gaps, `opacity-*` not reaching a subtree, "out of local registers", a motion driver taking the tree down at mount, and a slow build. ## "A class did nothing" Sorted by how often each turns out to be the answer. **1. The utility does not belong on that host element.** `<frame className="text-red-500" />` compiles and emits `TextColor3` onto a Frame, which rejects it. The static path warns `unsupported-host-utility` and writes it anyway. The runtime path drops it, so the same class list can behave differently on the two paths. **2. The class is on a component that does not forward it.** `<Panel className="p-4" />` hands `Panel` resolved props and helper children. If `Panel` does not spread its unrecognized props onto a host element and render `children`, everything vanishes with no warning. See [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#classname-on-components). **3. The element is on the runtime path and the class value is unchecked.** A typo that would warn in a literal is silent in a computed string. A runtime-path element renders as `VelaRuntimeHost` in the compiled output — see [dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md). **4. Your build drops Vela diagnostics entirely.** Diagnostics reach you through roblox-ts's `context.addDiagnostic`. On a version that does not expose it, every warning disappears. Confirm by compiling `className="bg-nope-500"` and checking that a warning appears. **5. The editor stayed quiet, but the key is wrong.** The editor suppresses `unknown-theme-key` while your token is still a prefix of a real key. `bg-slat` shows no squiggle and still warns in the build. **6. `transition` on a static element.** A static element never changes, so it is dropped with `transition-without-runtime`. Add the `hover:` or breakpoint rule that gives it something to tween. ## "The editor flags a class the build accepts" The build is the source of truth, so a squiggle the compiler disagrees with is an editor bug. Upgrade. They genuinely disagree in one direction by design. The per-host rules, reported as `unsupported-host-utility`, are enforced in the editor and never by the build. That is the first entry on this page. ## "A breakpoint or orientation variant never applies" Check the token's other conditions — a chained `md:touch:` needs both to match. Bare thresholds are min-width on the viewport's X dimension, so a phone-sized viewport matches none of them. A square viewport counts as `landscape`. ## "The layout is wrong" **`left-*` / `top-*` are ignored.** The parent has a `UIListLayout`, created by any `flex`, `gap-*` or `space-*`, and a list layout owns its children's positions. Position an element only under a plain frame. **An element sits outside its parent after `right-*` or `bottom-*`.** Those set the coordinate from the far edge but do not anchor the element. Its top-left corner lands there. Add the matching `origin-*`, as in `right-2 bottom-2 origin-bottom-right`. **`justify-*` behaves strangely.** `justify-center` and `justify-between` set two *different* properties (`HorizontalAlignment` vs `HorizontalFlex`), so writing both makes them interact inside the layout solver. Pick one. See [layout and sizing](https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing.md#justify--splits-across-two-properties). **Spacing is bigger than you asked for.** Margins are wrappers that participate in the parent's list, so they **sum** with its `gap-*`. Prefer gap alone for list spacing. **A grid stops laying out when you add `gap-*`.** Still open: gap on a `grid` element also emits a conflicting `UIListLayout`. Set `CellPadding` as a prop and keep `gap-*` off. **Children shuffle when `divide-*` is present.** Separators are inserted by child position and break under explicit `LayoutOrder`/`order-*`. Use source order with dividers. **`order-*` does nothing.** The layout instance is one you wrote yourself, and `SortOrder` defaults to `Name`, which sorts alphabetically and ignores `LayoutOrder`. Set it to `LayoutOrder`. Vela's own layouts set it for you. ## "The colors are wrong" **An element that used to be visible disappeared.** It relied on Roblox's default grey background, and [preflight](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight) starts every classed host element at `BackgroundTransparency = 1`. Give it the `bg-*` it was implicitly using, or set `preflight: false`. Elements with no `className`, and components, are never preflighted. **A gradient renders as a flat fill.** One of its stops is `transparent`, which is silently dropped. This is the one transparency case that produces no diagnostic. **`bg-*` seems ignored next to a gradient.** By design: a gradient forces `BackgroundColor3` to white so the stops read true, overriding any `bg-*` regardless of order. **A theme colour you defined reports `unknown-theme-key`.** Two suspects. A top-level `theme.colors` **silently discards `theme.extend.colors`** along with every built-in palette. And discovery walks up from each source file, so a stray `vela.config.ts` in a subdirectory shadows the root one. See [merge semantics](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#merge-semantics). **`opacity-*` fades a transparency you set yourself.** Expected when the fade comes from an ancestor *component*. The alpha arrives as React context, and a consumer cannot tell your `BackgroundTransparency` prop from one Vela lowered. **Overlapping siblings darken where they overlap.** `opacity-*` fades each instance rather than compositing the group. Put them in a `canvasgroup` and fade that. **A themed colour works statically but not on a dynamically-classed element.** The runtime path re-parses theme values from text. It accepts exactly `Color3.fromRGB(r, g, b)` and `new UDim(a, b)` with numeric literals. Anything else silently degrades. See [theming](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md). ## "Everything got bigger or smaller after upgrading to 0.12.0" Expected. Every pixel offset is a [rem unit](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem) following the viewport, so a layout tuned by eye at one resolution keeps its proportions there and changes everywhere else. A screen *uniformly* off means the viewport you are testing at. Off only at the extremes means the clamp is doing its job, at `min` 8 and `max` 64, both configurable. To rule rem out: ```ts title="vela.config.ts" export default defineConfig({ theme: { rem: { min: 16, max: 16 } } }); ``` **Text stopped growing past a point.** `TextSize` is capped at 100, where Roblox stops honoring it. ## "`@rbxts/vela-runtime` does not resolve" The runtime reaches your place as a dependency of `vela-rbxts`, and pnpm's default isolated linker does not put a transitive dependency where roblox-ts and Rojo look. Set the linker at the workspace root: ```yaml title="pnpm-workspace.yaml" nodeLinker: hoisted ``` npm and Yarn hoist by default. A nested `.npmrc` will not do it — pnpm reads `nodeLinker` only from the workspace root. ## "A margin renders unspaced under Vide, with a warning" A margin box is an instance *above* the element, and Vide parents an element as soon as it builds one. The box has to be decided before the element exists. The compiler handles any `m-*` it can see, in a static token or a branch, and warns on one arriving out of an opaque call. Name the token where the compiler can read it. See [the Vide guide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md). ## "`className` is a type error" The global augmentation only loads if `vela-rbxts` is imported somewhere in your program. Add the one-line declaration file — and do not name it `src/vela-rbxts.d.ts`, which makes the import resolve to itself under roblox-ts's `baseUrl`. See [installation](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md#declare-the-classname-prop). ## "The build fails on the first file with `compiler-invocation-failed`" If the message is `Cannot read properties of undefined (reading 'Latest')`, your TypeScript is too new. 7.x moved the API Vela's host adapter calls. Pin `typescript@5.9.3`, which the repo tests against. `6.x` works too. Any other `compiler-invocation-failed` means the native compiler call threw. Check the platform has a [prebuilt binary](https://docs.astra-void.xyz/vela-rbxts/reference/api.md#prebuilt-compiler-binaries) — Windows on ARM and linux arm64 musl do not. ## "Nothing is being transformed at all" - The file must end in `.tsx` — a `className` built in a `.ts` helper is invisible to Vela. - The tsconfig plugin must be `vela-rbxts/transformer`, the subpath. The root package's `createTransformer` is a different function, and passing it produces no transform and no error. - The transformer only runs under `rbxtsc` — it throws under plain `tsc`. - The file filter skips files with no literal `className` substring and no JSX open tag. Both are [toggleable](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#tsconfig-plugin-options), but on by default. ## "The output got bigger than expected" A file larger than you expect is usually one whose host has to **parse** a class value: a computed `className`, or a host taking a spread. That is the only case that keeps the full theme tables in the emit. Writing the branch out instead of interpolating it is often enough to drop them. ## Still stuck? Compile the smallest possible reproduction and read the emitted TSX before the Luau. A static-path element keeps its tag and gains plain props. A runtime-path element becomes `VelaRuntimeHost` with `__vela*` props. Which of the two you are looking at answers most questions on this page, and the [five rules](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#five-rules-that-keep-you-on-the-static-path) say how to move between them. --- # Utility reference > Every utility class, the exact values it accepts, and what it lowers to. Source: https://docs.astra-void.xyz/vela-rbxts/reference/utilities/ This is the complete list of what Vela compiles as of **0.13.0**. If a class is not on this page, Vela does not implement it. An unknown family produces `unsupported-utility-family`, and a real Tailwind family with no Roblox equivalent produces `no-roblox-equivalent`. A few get misread as a colour key instead. Anything that looks like `text-*`. See [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) for what each warning means. Every utility lowers to a **property**, a **helper instance** Vela prepends as a child, or, for the structural utilities, **extra frames** from the runtime helper. The helpers are `UIPadding`, `UIListLayout`, `UIGridLayout`, `UICorner`, `UIStroke`, `UIGradient`, `UISizeConstraint`, `UIAspectRatioConstraint`, `UIScale`, `UIShadow` and `UIFlexItem`. One of each per element, shared by every utility targeting it: `p-4 px-8` produces a single `UIPadding`, and `border-*`, `ring-*` and `outline-*` all share one `UIStroke`. Most utilities resolve entirely at compile time. Three families are **runtime-structural**. Margins, `divide-*` and `animate-*` build wrappers, separators or loops that cannot be static props. Any of them moves the element onto the [runtime path](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#the-two-lowering-paths), even in a plain string literal. Each is marked below. ## Supported elements Vela lowers `className` on these eight intrinsic elements, and on components. | Element | Notes | |---|---| | `frame` | Layout container | | `scrollingframe` | Layout container, plus `overscroll-*` and the [scrolling families](#scrolling) | | `canvasgroup` | Layout container; `opacity-*` fades the subtree as one composited layer and stops there | | `textlabel` | Text properties allowed | | `textbutton` | Text properties allowed | | `textbox` | Text properties allowed, plus `placeholder-*` | | `imagelabel` | `image-*` and `object-*` allowed | | `imagebutton` | `image-*` and `object-*` allowed | | Any component | An uppercase tag (`<Panel />`) or a member expression (`<Switch.Root />`). Every utility is allowed | Anything else is skipped with its `className` left untouched, reporting `classname-on-unsupported-host`. That covers lowercase intrinsics Vela does not implement, such as `screengui`, and namespaced tags such as `<svg:rect />`, which never count as components. The warning names only the local part, so that one is reported as `rect`. Text utilities are only meaningful on `textlabel`, `textbutton` and `textbox`. That covers `text-*` colour and size, `font-*`, `italic`, `leading-*`, alignment, wrapping, truncation, the case transforms and the decorations. `image-*` and `object-*` belong on `imagelabel` and `imagebutton`, `placeholder-*` on `textbox`, and `overscroll-*` and the [scrolling families](#scrolling) on `scrollingframe`. **The two paths enforce that differently.** The runtime host **drops** a utility the host cannot carry. The static path warns `unsupported-host-utility` and writes it anyway, so `<frame className="text-red-500" />` still emits a `TextColor3` assignment onto a Frame — see [still open](https://docs.astra-void.xyz/vela-rbxts/reference/release-notes.md#still-open). On a component none of this applies — the host it renders is unknown, so every utility is offered. Whether the props land is up to the component. It must forward what it does not consume down to a host element, and nothing checks that it does. See [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#classname-on-components). ## Color | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `bg-{color}` | theme color key, `[#hex]` | `BackgroundColor3` + `BackgroundTransparency` | | | `text-{color}` | theme color key, `[#hex]` | `TextColor3` + `TextTransparency` | Fallback branch of `text-*` | | `image-{color}` | theme color key, `[#hex]` | `ImageColor3` + `ImageTransparency` | | | `placeholder-{color}` | theme color key, `[#hex]` | `PlaceholderColor3` | No transparency channel | | `border-{color}` | theme color key, `[#hex]` | `UIStroke.Color` + `UIStroke.Transparency` | Helper instance | | `ring-{color}`, `outline-{color}` | theme color key, `[#hex]` | `UIStroke.Color` + `UIStroke.Transparency` | Same `UIStroke` as `border-*` | | `shadow-{color}` | theme color key, `[#hex]` | `UIShadow.Color` | Helper instance | | `divide-{color}` | theme color key, `[#hex]` | separator `BackgroundColor3` + `BackgroundTransparency` | Runtime-structural | | `from-{color}`, `via-{color}`, `to-{color}` | theme color key, `[#hex]` | `UIGradient.Color` keypoints + `UIGradient.Transparency` | Helper instance | A colour key is either a literal, such as `black` or `white`, or a palette family plus one of eleven shades from `50` to `950`. The shade is split off with the last `-` in the token, and only if the suffix is exactly one of those eleven numbers. So `bg-my-color` is looked up as the single key `my-color`, not as family `my` with shade `color`. A palette may carry a `DEFAULT`, which a bare family name resolves to. Every built-in palette ships one mirroring its `500`, so `bg-slate` and `text-blue` work with no config. A palette of your own resolves bare only if you give it a `DEFAULT`. Without one, the bare form is `color-missing-shade`. `DEFAULT` is not spellable in a class: `bg-slate-DEFAULT` is read as the single key `slate-DEFAULT`. **Arbitrary hex colours** work in every colour family, as in `bg-[#3b82f6]` or `text-[#fff]`. Three- and six-digit payloads go straight to `Color3.fromRGB(...)`. A bracket payload that is not a hex colour reports `unsupported-arbitrary-value`. **Opacity modifiers** append `/N` to a color, where `N` is an integer from 0 to 100: `bg-blue-600/50` emits the color plus `BackgroundTransparency = 0.5`. Every family Roblox gives a transparency channel lowers it: | Family | `/N` lowers to | |---|---| | `bg-*` | `BackgroundTransparency` | | `text-*` | `TextTransparency` | | `image-*` | `ImageTransparency` | | `shadow-*` | `UIShadow.Transparency` | | `border-*`, `ring-*`, `outline-*` | `UIStroke.Transparency` | | `divide-*` | the separator frames' `BackgroundTransparency` | | `from-*`, `via-*`, `to-*` | a `UIGradient.Transparency` sequence whose keypoints line up with the color stops | A gradient's keypoints line up per stop, so fading one does not fade its neighbours. `placeholder-*` is the one family left: Roblox has no placeholder transparency, so it reports `unsupported-opacity-modifier`. Two keywords behave specially. `transparent` sets the transparency to `1` and removes the colour property, and on `placeholder-*` and `divide-*` it is `unsupported-color-key`. `current` and `inherit` are rejected outright. Note the asymmetry that leaves on `divide-*`: `divide-white/10` resolves while `divide-transparent` is refused — the modifier and the keyword are separate parsers. Gradient stops report nothing at all: `from-transparent`, `via-transparent` and `to-transparent` are **silently dropped**, and the stop simply does not appear in the `ColorSequence`. Getting the shade wrong is its own diagnostic. A palette with neither a shade nor a `DEFAULT` gives `color-missing-shade`. A literal with a shade, or a palette missing that shade, gives `color-invalid-shade`. A name that is not in the theme at all gives `unknown-theme-key`. > **text-* is greedy** > > `text-*` is classified in one pass: known size keys become `TextSize`, `left`/`center`/`right`/`justify` > become `TextXAlignment`, `wrap`/`nowrap` become `TextWrap`, and **everything else falls through to > the color branch**. That means a typo like `text-smal` is not reported as an unknown utility — it is > reported as `unknown-theme-key` for a color named `smal`. ## Border, ring, and outline Everything here writes into a single shared `UIStroke` helper instance. | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `border` | — | `UIStroke.Thickness = 1` | Bare form | | `border-{n}` | `0`, `1`, `2`, `4` | `UIStroke.Thickness` | No other numbers; use `border-[3px]` | | `border-transparent` | — | `UIStroke.Transparency = 1` | | | `border-{join}` | `round`, `bevel`, `miter` | `UIStroke.LineJoinMode` | | | `border-{color}` | theme color key, `[#hex]` | `UIStroke.Color`, `Transparency = 0` | | | `ring` | — | `UIStroke.Thickness = 3` | Also sets `ApplyStrokeMode = Border` | | `ring-{n}` | `0`, `1`, `2`, `4`, `8` | `UIStroke.Thickness` | | | `outline` | — | `UIStroke.Thickness = 2` | Also sets `ApplyStrokeMode = Border` | | `outline-none`, `outline-hidden` | — | `UIStroke.Thickness = 0` | | | `border-[{n}]`, `ring-[{n}]`, `outline-[{n}]` | pixels, with or without the unit | `UIStroke.Thickness` | Arbitrary value, since 0.7.0. A bracket is read as a color first | `border-*`, `ring-*` and `outline-*` **all merge into the same helper**. On a collision the later token wins. They do not stack the way CSS rings stack on borders. The one real difference is that `ring` and `outline` set `ApplyStrokeMode = Border`, pinning the stroke to the element's bounds. A bracketed thickness resolves on all three, with or without the unit: `border-[3px]`, `ring-[3px]`, `outline-[2px]`. A bracket is parsed as an arbitrary *color* first, so `border-[#0f0]` is the color and `border-[3px]` the thickness. See [arbitrary values](#arbitrary-values). For `border-*`, four things are explicitly rejected with `unsupported-border-value`. The CSS border styles `dashed`, `solid`, `dotted` and `double`. Every side-specific form, such as `border-x` or `border-t-2`. `border-opacity-*`. And anything containing a slash. For `ring-*`/`outline-*`: `inset`, `solid`, `dashed`, `dotted`, `double`, `offset-*`, and any other unbracketed thickness (`unsupported-stroke-value`). ## Radius | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `rounded` | — | `UICorner.CornerRadius` | The theme's `DEFAULT` radius, 4px by default | | `rounded-{key}` | any key in `theme.radius` | `UICorner.CornerRadius` | Pure theme lookup | | `rounded-[{n}]` | pixels, percent | `UICorner.CornerRadius` | Arbitrary value, since 0.7.0 | | `rounded-t-{key}`, `rounded-b-{key}` | any key in `theme.radius`, or a bracketed value | `UICorner.TopLeftRadius` + `TopRightRadius`, `BottomLeftRadius` + `BottomRightRadius` | Since 0.13.0. The other pair is squared to `0` | | `rounded-l-{key}`, `rounded-r-{key}` | any key in `theme.radius`, or a bracketed value | `UICorner.TopLeftRadius` + `BottomLeftRadius`, `TopRightRadius` + `BottomRightRadius` | Since 0.13.0. The other pair is squared to `0` | | `rounded-tl-{key}`, `rounded-tr-{key}`, `rounded-bl-{key}`, `rounded-br-{key}` | any key in `theme.radius`, or a bracketed value | The one matching `UICorner` radius property | Since 0.13.0. Beats `rounded-{key}` on the corner it names, in either order | The default theme ships the `DEFAULT` plus ten named keys: `none`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `4xl`, `full`. There is no numeric fallback, so `rounded-8` is `unknown-theme-key`. A bracketed value is read directly: `rounded-[6px]` and `rounded-[6]` both give `new UDim(0, 6)`. **A directional form writes the individual corner properties, and squares off the corners it does not name** — so `rounded-r-lg` alone keeps the left side flat. That makes it beat the all-corner shorthand on the corners it names, in either order: ```tsx title="Both round the left pair to lg and the right pair to md" <frame className="rounded-l-lg rounded-md" /> <frame className="rounded-md rounded-l-lg" /> ``` A variant repaints what the base left open: `rounded-l-lg hover:rounded-md`. ## Shadow | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `shadow` | — | `UIShadow` preset | blur 3, offset Y 1, spread 0, transparency `0.9` | | `shadow-sm` | — | `UIShadow` preset | 2 / 1 / 0 / `0.95` | | `shadow-md` | — | `UIShadow` preset | 6 / 4 / −1 / `0.9` | | `shadow-lg` | — | `UIShadow` preset | 15 / 10 / −3 / `0.9` | | `shadow-xl` | — | `UIShadow` preset | 25 / 20 / −5 / `0.9` | | `shadow-2xl` | — | `UIShadow` preset | 50 / 25 / −12 / `0.75` | | `shadow-none` | — | `UIShadow.Enabled = false` | | | `shadow-{color}` | theme color key, `[#hex]` | `UIShadow.Color` | `shadow-transparent` instead sets `Transparency = 1` | | `shadow-inner` | — | — | `unsupported-shadow-inset` | Presets emit `BlurRadius = new UDim(0, blur)`, `Offset = UDim2.fromOffset(0, offsetY)`, `Spread` only when it is non-zero, and `Transparency`. ## Gradient | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `bg-gradient-to-{dir}` | `t`, `tr`, `r`, `br`, `b`, `bl`, `l`, `tl` | `UIGradient.Rotation` | Rotations 270, 315, 0, 45, 90, 135, 180, 225 | | `bg-linear-to-{dir}` | same | same | Accepted alias | | `from-{color}`, `via-{color}`, `to-{color}` | theme color key, `[#hex]` | `UIGradient.Color` | | Stops flush into a `ColorSequence`. `Rotation` is emitted only when non-zero. `BackgroundColor3` is forced to white so the gradient is not tinted by the element's own background. That overrides a `bg-*` on the same element, regardless of token order. A bad direction is `unsupported-gradient-direction`. ## Z-index | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `z-{n}` | `0`, `10`, `20`, `30`, `40`, `50` | `ZIndex` | Exactly these six | | `z-[{n}]` | any non-negative integer | `ZIndex` | Arbitrary value, since 0.7.0 | `z-[15]` and `z-[999]` are how you reach a level the six presets do not cover. The payload has to be a whole number, because `ZIndex` is an integer: `z-[1.5]` keeps `unsupported-arbitrary-z-index`. Everything else is a distinct diagnostic: `z-auto` gives `unsupported-z-index-auto`, a negative `-z-N` or `-z-[N]` gives `unsupported-negative-z-index`, and any other unbracketed number gives `unsupported-z-index-value`. ## Padding All padding writes into a single `UIPadding` helper instance. | Class | Values | Roblox target | | --- | --- | --- | | `p-{key}` | spacing value | `PaddingTop`, `PaddingRight`, `PaddingBottom`, `PaddingLeft` | | `px-{key}` | spacing value | `PaddingLeft`, `PaddingRight` | | `py-{key}` | spacing value | `PaddingTop`, `PaddingBottom` | | `pt-{key}` | spacing value | `PaddingTop` | | `pr-{key}` | spacing value | `PaddingRight` | | `pb-{key}` | spacing value | `PaddingBottom` | | `pl-{key}` | spacing value | `PaddingLeft` | ### Spacing values A spacing key resolves against `theme.spacing` first, then falls back to arithmetic: unsigned, finite, at least zero, a multiple of `0.5`, giving `new UDim(0, key * 4)`. So `p-1.5` is 6 pixels, `p-40` is 160, and `p-0.25` is `unknown-theme-key`. The default theme contains exactly one spacing key, `"4"` — every other number comes from the fallback. ## Arbitrary values A bracketed payload names a value directly instead of going through a scale. Two kinds exist, and which one a family reads depends on the family. **Colors** — `[#rgb]` and `[#rrggbb]`, in every color family. **Lengths** — `[16px]`, `[16]`, `[50%]`, `[1rem]` and their negatives. The unit is optional, and a bare number means pixels. A percentage becomes the scale half of a `UDim`, so `w-[50%]` is `UDim2.fromScale(0.5, 0)` and `w-[120px]` is `UDim2.fromOffset(120, 0)`. A `rem` payload resolves against `theme.rem.base` and follows the viewport like any other offset, so `text-[1.5rem]` says what `text-[24px]` says. | Family | Reads a bracket as | Example | |---|---|---| | Spacing — `p-*`, `m-*`, `gap-*`, `space-*` | Length | `p-[12px]`, `-mt-[8px]` | | Size — `w-*`, `h-*`, `size-*`, `min-*`, `max-*` | Length | `w-[120px]`, `h-[50%]` | | Position — `left-*`, `top-*`, `inset-*`, `translate-*` | Length | `left-[25%]`, `-left-[10px]` | | Radius — `rounded-*` | Length | `rounded-[6px]` | | Scrollbar width — `scrollbar-w-*` | Length | `scrollbar-w-[6px]` | | `text-*` | Length, read as `TextSize` | `text-[13px]` | | `leading-*` | Number, read as `LineHeight` | `leading-[1.6]` | | `rotate-*` | Number, read as degrees | `rotate-[17deg]` | | `z-*` | Integer, read as `ZIndex` | `z-[15]` | | `border-*`, `ring-*`, `outline-*` | Length, read as `Thickness` | `border-[3px]` | | `aspect-*` | Ratio | `aspect-[16/9]` | Both paths resolve these identically. A payload the family cannot read reports `unsupported-arbitrary-value` and is dropped. `z-[1.5]` has its own `unsupported-arbitrary-z-index`, since `ZIndex` is an integer. A space inside the brackets does not end the class, so `w-[calc(100% - 4px)]` is one token reporting one diagnostic. (`calc()` is still not a payload any family reads.) The runtime splits its class strings under the same rule. A bracket that never closes still splits, which keeps the classes written after a typo applying. Families not in the table above do not take a bracket. There is no arbitrary `font-[…]` weight, no arbitrary `duration-[…]`, and no arbitrary grid template. ## Margin Roblox has no margin box, so Vela builds one. Positive margins wrap the element in a transparent frame padded by the margin values, with the element's layout-facing props routed onto the wrapper. No static prop can express that, so **any margin utility moves the element onto the [runtime path](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md)**, plain string literal or not. | Class | Values | Effect | Notes | | --- | --- | --- | --- | | `m-{key}` | spacing value | margin box, all four sides | Runtime-structural | | `mx-{key}`, `my-{key}` | spacing value | margin box, one axis | Runtime-structural | | `mt-{key}`, `mr-{key}`, `mb-{key}`, `ml-{key}` | spacing value | margin box, one side | Runtime-structural | | `-mt-{key}`, `-ml-{key}` | spacing value | `Position` shift | Negative top/left margins move, not wrap | | `-mr-{key}`, `-mb-{key}` | — | — | `unsupported-negative-margin` | | `mx-auto` | — | `AnchorPoint.X = 0.5`, `Position.X = 0.5` scale | **Static** — centers without a wrapper | | `my-auto` | — | `AnchorPoint.Y = 0.5`, `Position.Y = 0.5` scale | **Static** | Margins sum with the parent's `gap-*` — a `gap-2` list whose children carry `my-2` gives 16-pixel gaps, not 8. For space between list children, prefer `gap-*` on the parent: one property on one instance, and it keeps the element on the static path. A negative top or left margin moves the element rather than padding the box around it. **Each side holds one signed value, and the last class written to it wins.** `ml-4 -ml-2` shifts by 8. `-ml-2 ml-4` pads by 16. `-ml-0` clears what came before it. `-mr-*` and `-mb-*` remain `unsupported-negative-margin` — those would have to pull the *next* sibling closer. ## Gap and space | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `gap-{key}` | spacing value | `UIListLayout.Padding` | No `gap-x-` or `gap-y-` | | `space-x-{key}` | spacing value | `UIListLayout.Padding` + `FillDirection = Horizontal` | | | `space-y-{key}` | spacing value | `UIListLayout.Padding` + `FillDirection = Vertical` | | Roblox's `UIListLayout` has one padding axis, so there is no axis-specific gap to expose. `space-x-*` and `space-y-*` are the Tailwind spellings of the same property with a direction attached — `space-y-2` is exactly `flex flex-col gap-2`. An unresolvable value is `unsupported-space-value`. `space-x-reverse` is not implemented. On a `grid` element, `gap-*` also feeds `UIGridLayout.CellPadding` — see the [grid caveat](#grid) below. ## Divide `divide-*` inserts separator frames **between** the element's content children — the Tailwind borders-between-children idiom. Like margins, separators are structural, so **any divide utility moves the element onto the runtime path**. | Class | Values | Effect | | --- | --- | --- | | `divide-x`, `divide-y` | — | 1px separators between children, horizontal / vertical | | `divide-x-{n}`, `divide-y-{n}` | `0`, `1`, `2`, `4`, `8` | separator thickness | | `divide-{color}` | theme color key, `[#hex]` | separator `BackgroundColor3` | Two sharp edges. Separators are themselves list items, so the parent's `gap-*` applies on **both** sides of each separator — unlike CSS, where a divider lives inside the gap. And explicit `LayoutOrder` values on the children break separator ordering, because the separators are inserted by child position, not by layout order. `divide-transparent` reports `unsupported-color-key` (separators have no transparency channel), and an unresolvable payload is `unsupported-divide-value`. ## Size | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `w-{value}` | `px`, `full`, fractions, spacing offsets | `Size` X component | Merges with `h-` | | `h-{value}` | `px`, `full`, fractions, spacing offsets | `Size` Y component | Merges with `w-` | | `size-{value}` | `px`, `full`, fractions, spacing offsets | both axes | | | `basis-{value}` | `px`, `full`, fractions, spacing offsets | `Size` X component | Effectively `w-*` regardless of fill direction | | `w-auto`, `w-fit` | — | `AutomaticSize = Enum.AutomaticSize.X` | | | `h-auto`, `h-fit` | — | `AutomaticSize = Enum.AutomaticSize.Y` | | | `size-auto`, `size-fit` | — | `AutomaticSize = Enum.AutomaticSize.XY` | Also when both axes are set | Accepted values on the sizing axes: - `px` — one pixel of offset. - `full` — scale `1`. - Fractions come from an exact allowlist: `1/2`, `1/3`, `2/3`, `1/4`, `3/4`, `1/5`, `2/5`, `3/5`, `4/5`, `1/6`, `5/6`, and `1/12` through `11/12`. Unreduced forms such as `2/4`, `3/6`, and `2/6` are **not** accepted. - Any spacing value, used as an offset. If the resolved spacing carries a non-zero scale component, it is rejected with `unsupported-size-spacing-value`. `w-` and `h-` on the same element merge into one `Size` property. The emitted form depends on the values. Both scales zero gives `UDim2.fromOffset(x, y)`, both offsets zero gives `UDim2.fromScale(x, y)`, and a mix gives the full `UDim2.new(sx, ox, sy, oy)`. `basis-*` lowers onto the X axis of `Size` regardless of fill direction — it is `w-*` under another name. Use it only when porting Tailwind markup. ## Constraints | Class | Values | Roblox target | | --- | --- | --- | | `min-w-{key}` | spacing offset | `UISizeConstraint.MinSize` X | | `min-h-{key}` | spacing offset | `UISizeConstraint.MinSize` Y | | `max-w-{key}` | spacing offset | `UISizeConstraint.MaxSize` X | | `max-h-{key}` | spacing offset | `UISizeConstraint.MaxSize` Y | Emitted as `new Vector2(x, y)`. An axis you do not specify gets a default: `0` for `MinSize` and `math.huge` for `MaxSize`. These are offset-only — no fractions, no `full`. ## Position | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `left-{value}` | `px`, `full`, fractions, spacing offsets | `Position` X | | | `top-{value}` | same | `Position` Y | | | `right-{value}` | same | `Position` X, from the far edge | `right-2` is `new UDim(1, -8)` | | `bottom-{value}` | same | `Position` Y, from the far edge | | | `inset-{value}` | same | both axes | | | `-left-{value}`, `-top-{value}`, `-inset-{value}` | same | negated | | The fraction set is the same allowlist as sizing. `right-*` and `bottom-*` position from the far edge but do not touch `AnchorPoint`, so the element's top-left corner still lands there unless you anchor it. Pair them with `origin-*`: `right-2 bottom-2 origin-bottom-right` puts the bottom-right corner 8 pixels inside its parent's. A parent with a `UIListLayout` owns its children's positions, so none of these classes take effect. Any `flex`, `gap-*` or `space-*` utility creates one. Remove the layout utilities from the parent instead. ## Anchor | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `origin-{key}` | `top-left`, `top`, `top-right`, `left`, `center`, `right`, `bottom-left`, `bottom`, `bottom-right` | `AnchorPoint` | Emitted as `new Vector2(x, y)` | Anything outside those nine keys is `unsupported-anchor-value`. ## Flex layout These write into a `UIListLayout` helper instance. | Class | Roblox target | Notes | | --- | --- | --- | | `flex` | `FillDirection = Enum.FillDirection.Horizontal` | Bare form. Every form also sets `SortOrder = LayoutOrder` | | `flex-row` | `FillDirection = Enum.FillDirection.Horizontal` | | | `flex-col` | `FillDirection = Enum.FillDirection.Vertical` | | | `flex-wrap` | `Wraps = true` | | | `flex-nowrap` | `Wraps = false` | | | `justify-start`, `justify-center`, `justify-end` | `HorizontalAlignment = Left, Center, Right` | | | `justify-between`, `justify-around`, `justify-evenly` | `HorizontalFlex = Enum.UIFlexAlignment.SpaceBetween, SpaceAround, SpaceEvenly` | Different property | | `items-start`, `items-center`, `items-end` | `VerticalAlignment = Top, Center, Bottom` | | | `justify-stretch` | `HorizontalFlex = Enum.UIFlexAlignment.Fill` | Different property. Since 0.12.7 | | `items-stretch` | `VerticalFlex = Enum.UIFlexAlignment.Fill` | Different property | | `content-{start,center,end}` | `VerticalAlignment` | | | `content-{between,around,evenly,stretch}` | `VerticalFlex` | | Any other `flex-{something}` that is not a flex-item keyword is `unsupported-flex-direction`. An unresolvable `justify-*`, `items-*`, or `content-*` value is `unsupported-alignment-value`. `justify-*` and `items-*` are named after the Roblox layout's horizontal and vertical axes, not the flex main and cross axes, so they do not swap under `flex-col`. The fill alignments follow the same split: `items-stretch` writes `VerticalFlex`, `justify-stretch` writes `HorizontalFlex`. ## Flex items These write into a `UIFlexItem` helper instance on the child. | Class | Roblox target | | --- | --- | | `flex-1` | `FlexMode = Enum.UIFlexMode.Fill` | | `flex-auto` | `FlexMode = Enum.UIFlexMode.Fill` | | `flex-initial` | `FlexMode = Enum.UIFlexMode.Shrink` | | `flex-none` | `FlexMode = Enum.UIFlexMode.None` | | `grow` | `FlexMode = Enum.UIFlexMode.Grow` | | `grow-0` | `FlexMode = Enum.UIFlexMode.None` | | `shrink` | `FlexMode = Enum.UIFlexMode.Shrink` | | `shrink-0` | `FlexMode = Enum.UIFlexMode.None` | | `self-{auto,start,center,end,stretch}` | `ItemLineAlignment` | There is no `grow-{n}`, no `shrink-{n}` — numeric variants are not recognized. ## Grid | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `grid` | — | `UIGridLayout` child, `SortOrder = LayoutOrder` | | | `grid-cols-{n}` | `1`–`12` | `FillDirection = Horizontal`, `FillDirectionMaxCells = n`, `CellSize.X` | | | `grid-rows-{n}` | `1`–`12` | `FillDirection = Vertical`, `FillDirectionMaxCells = n`, `CellSize.Y` | | | `auto-rows-{n}` | spacing scale, arbitrary | `CellSize.Y` | Cross axis of `grid-cols-*` | | `auto-cols-{n}` | spacing scale, arbitrary | `CellSize.X` | Cross axis of `grid-rows-*` | Anything else in the grid family is not implemented, because `UIGridLayout` has no span concept. That includes `col-span-*`, `row-span-*` and template values. An unresolvable count is `unsupported-grid-value`. **`grid-cols-*` sizes the cells.** `UIGridLayout` stamps `CellSize` onto every child and ignores the child's own `Size`, so the count decides the track width. `grid-cols-N` divides the axis into N tracks and gives each cell its share of the gap: ```tsx title="grid grid-cols-2 gap-2.5" <uigridlayout FillDirection={Enum.FillDirection.Horizontal} FillDirectionMaxCells={2} CellSize={new UDim2(0.5, -5, 0, 100)} CellPadding={UDim2.fromOffset(10, 10)} /> ``` The cross axis stays at the engine's 100px until `auto-rows-*` names it. `auto-cols-*` does the same for `grid-rows-*`. Both read the [spacing scale](#spacing-values), so `auto-rows-24` is 96px. > **Keep gap-* off grid elements for now** > > `gap-*` on a `grid` element writes `UIGridLayout.CellPadding`, the right target. But it **also** emits an ordinary `UIListLayout` carrying the same value, leaving two layouts fighting over the children. Set `CellPadding` as a prop and leave `gap-*` off. ## Order | Class | Values | Roblox target | | --- | --- | --- | | `order-{n}`, `-order-{n}` | any integer | `LayoutOrder` | | `order-first` | — | `LayoutOrder = -9999` | | `order-last` | — | `LayoutOrder = 9999` | | `order-none` | — | `LayoutOrder = 0` | An unresolvable payload is `unsupported-layout-order-value`. Remember `divide-*` separators are positioned by child order, not `LayoutOrder` — mixing the two breaks separator placement. `LayoutOrder` only decides anything when the parent's layout sorts by it. Vela's layouts set `SortOrder = LayoutOrder`, so `order-*` works under a `flex` or `grid` parent. A layout **you** wrote keeps the engine default of `Name`, which sorts alphabetically and ignores `order-*` — that was also Vela's own bug until [0.5.0](https://docs.astra-void.xyz/vela-rbxts/reference/release-notes.md#050). ## Aspect ratio | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `aspect-square` | — | `UIAspectRatioConstraint.AspectRatio = 1` | | | `aspect-video` | — | `AspectRatio = 1.7777777778` | | | `aspect-[W/H]` | positive finite numbers | `AspectRatio` | Arbitrary value | | `aspect-[N]` | positive finite number | `AspectRatio` | Arbitrary value | | `aspect-auto` | — | — | `unsupported-aspect-value` | Arbitrary bracket values are accepted here on the compile-time path, unlike `rounded-*`. ## Transform | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `rotate-{deg}`, `-rotate-{deg}` | `0`, `1`, `2`, `3`, `6`, `12`, `45`, `90`, `180` | `Rotation` | `-rotate-0` stays `0` | | `scale-{n}` | `0`, `50`, `75`, `90`, `95`, `100`, `105`, `110`, `125`, `150` | `UIScale.Scale` | Maps to `0`, `0.5`, `0.75`, `0.9`, `0.95`, `1`, `1.05`, `1.1`, `1.25`, `1.5` | | `translate-x-{v}`, `translate-y-{v}` | fractions or spacing values | `AnchorPoint` or `Position` | A fraction anchors; a pixel value shifts | Degrees outside the list give `unsupported-rotation-value`. Scales outside the list give `unsupported-scale-value`. Scaling is uniform — there is no `scale-x-*`/`scale-y-*`. `translate-*` splits by value kind. A **fraction** lowers to `AnchorPoint`, a shift measured in the element's own size. A **pixel** value adds to the `Position` offset. So the CSS centering idiom works verbatim: ```tsx title="AnchorPoint (0.5, 0.5) + Position (0.5, 0.5) scale" <frame className="left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> ``` `mx-auto`/`my-auto` compile to the same centering — pick whichever reads better. ## Effects | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `opacity-{n}` | any integer `0`–`100` | Every transparency channel the element paints, and the subtree under it | Inverted: `opacity-100` is fully opaque | | `opacity-{n}` | any integer `0`–`100` | `GroupTransparency`, on a `canvasgroup` | One composited layer; the fade stops there | The value is converted to transparency as `100 - n`. Any non-integer or out-of-range value gives `unsupported-opacity-value`. **It fades everything the element draws.** That is `BackgroundTransparency` on every host, `TextTransparency` on the text hosts and `ImageTransparency` on the image hosts. It also covers the `Transparency` of a `UIStroke` or `UIShadow` the same class list drew. **And the subtree under it.** Roblox has no inherited transparency. Vela hands every instance below the class a running product: `1 - (1 - own) * alpha`. Children written inside an expression count, and so do `{props.children}` and component children, which the alpha reaches through React context. A `canvasgroup` ends the descent — its `GroupTransparency` composites the subtree in one pass. **It is order-independent, and multiplies with `/N`.** The utility is held until the whole class list is read, then composed over whatever alpha the colours settled on. `opacity-50 bg-blue-600/50` lands at 0.75 transparency rather than one winning. Use `/N` to fade *one* family, `opacity-*` for the element and its subtree. It parts ways with a real CSS composite in two places. Overlapping siblings are faded individually, so the overlap darkens. And a fade arriving from an ancestor *component* composes over a `BackgroundTransparency` you declared as a prop. A `canvasgroup` gives the real composite in both cases. ## Motion Vela can describe transitions and looping animations, executed by `TweenService` inside the runtime helper. | Class | Values | Effect | Notes | | --- | --- | --- | --- | | `transition` | — | Tween property changes caused by variant rules | | | `transition-all` | — | Tween every property a rule changes | | | `transition-colors` | — | Narrow the tween to the color properties | | | `transition-opacity` | — | Narrow the tween to the transparency properties | | | `transition-transform` | — | Narrow the tween to `Position`, `Rotation`, `UIScale` | | | `transition-shadow` | — | Narrow the tween to the `UIShadow` properties | Since 0.13.0, when transitions began moving the helper instances | | `transition-none` | — | Disable tweening | | | `duration-{n}` | preset `75`–`1000`, or any integer ms | Tween time, `n / 1000` seconds | | | `delay-{n}` | preset `75`–`1000`, or any integer ms | Tween delay | | | `ease-{linear,in,out,in-out}` | — | `EasingStyle` / `EasingDirection` | | | `animate-{spin,pulse,bounce}` | — | Preset looping animation | Runtime-structural | | `animate-none` | — | No animation | | Defaults are `0.15` seconds and `Quad`/`Out`. `duration-*`, `delay-*` and `ease-*` each enable the transition on their own, so `duration-200` alone is a complete transition spec. Two constraints. A transition needs a property that *changes*, from a [variant](https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants.md) rule or a dynamic `className`. On a purely static element it is dropped with `transition-without-runtime`. And both families need a real instance to drive, so on a component they warn `motion-on-component`. ```tsx title="A hover transition, complete" <textbutton className="bg-sky-500 hover:bg-sky-600 transition duration-150" Text="Play" /> ``` **The property filters are real.** Each holds the tween to its own group. A rule changing a colour and a position under `transition-colors` snaps the position and tweens the colour. Transitions reach the **helper instances** too, so `hover:rounded-xl` and `hover:border-blue-500` tween rather than snapping, and `transition-shadow` is a working filter. An unresolvable value is `unsupported-transition-value` or `unsupported-animation-value`. A [motion driver](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#motion-driver) can replace TweenService for this whole section, one method at a time. ## Typography | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `text-{size}` | `xs` 12, `sm` 14, `base` 16, `lg` 18, `xl` 20, `2xl` 24, `3xl` 30, `4xl` 36, `5xl` 48, `6xl` 60, `7xl` 72, `8xl` 96, `9xl` 128 | `TextSize` | Pixel values shown | | `font-{weight}` | `thin`, `extralight`, `light`, `normal`, `medium`, `semibold`, `bold`, `extrabold`, `black` | `FontFace` weight axis | `normal` is `Regular`, `black` is `Heavy` | | `font-{family}` | any key in `theme.fontFamily` — `sans`, `serif`, `mono` by default | `FontFace` family axis | The fallback branch of `font-*` | | `italic`, `not-italic` | — | `FontFace` style axis | Merges with the weight | | `leading-{key}` | `none` 1, `tight` 1.25, `snug` 1.375, `normal` 1.5, `relaxed` 1.625, `loose` 2 | `LineHeight` | Numeric forms are `unsupported-line-height-value` | | `text-left`, `text-center`, `text-right` | — | `TextXAlignment` | | | `text-justify` | — | — | `unsupported-text-alignment` | | `align-top`, `align-middle`, `align-bottom` | — | `TextYAlignment = Top, Center, Bottom` | | | `text-wrap`, `text-nowrap` | — | `TextWrapped = true, false` | | | `whitespace-normal`, `whitespace-nowrap` | — | `TextWrapped = true, false` | Alias family; others are `unsupported-whitespace-value` | | `truncate` | — | `TextTruncate = Enum.TextTruncate.AtEnd` | | | `uppercase`, `lowercase`, `capitalize`, `normal-case` | — | the `Text` string itself | Rewritten at compile time when `Text` is a literal | | `underline`, `line-through`, `no-underline` | — | RichText markup around `Text` | Backs off with `decoration-on-richtext` if you set `RichText` | **`font-*` is one prefix over three axes**, and they merge into a single `Font` value. The emit is `new Font(family, Enum.FontWeight.X, Enum.FontStyle.Italic)`, with the style argument present only when `italic` is. The prefix resolves the fixed weight names first and reads anything else as a key in [`theme.fontFamily`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#fontfamily), so `font-bold font-mono italic` lowers to one `FontFace`: ```tsx title="font-mono font-bold italic" FontFace={new Font("rbxasset://fonts/families/RobotoMono.json", Enum.FontWeight.Bold, Enum.FontStyle.Italic)} ``` The default scale is `sans` (Source Sans Pro), `serif` (Merriweather) and `mono` (Roboto Mono), and takes any Roblox font family asset, uploaded `rbxassetid://` fonts included. Two weight names do not map one-to-one: `normal` becomes `Regular`, `black` becomes `Heavy`. Since the family lookup is the fallback, a `font-*` payload that is neither reports `unknown-theme-key` rather than a weight error. **Case transforms rewrite the string.** When `Text` is a literal, the transform happens at compile time — `<textlabel className="uppercase" Text="hello" />` emits `Text="HELLO"` and nothing else. When `Text` is an expression, the transform runs in the runtime helper instead. **Decorations use RichText.** `underline` emits `Text="<u>hello</u>"` with `RichText={true}`, escaping as needed. If the element already sets `RichText`, Vela backs off and reports `decoration-on-richtext`. ## Images | Class | Roblox target | | --- | --- | | `object-cover` | `ScaleType = Enum.ScaleType.Crop` | | `object-contain` | `ScaleType = Enum.ScaleType.Fit` | | `object-fill` | `ScaleType = Enum.ScaleType.Stretch` | | `object-tile` | `ScaleType = Enum.ScaleType.Tile` | `object-tile` is a Roblox-only extension with no Tailwind counterpart. Other values are `unsupported-object-fit-value`. ## Interaction | Class | Roblox target | | --- | --- | | `pointer-events-none` | `Interactable = false` | | `pointer-events-auto` | `Interactable = true` | | `overscroll-auto` | `ElasticBehavior = Enum.ElasticBehavior.Always` | | `overscroll-contain` | `ElasticBehavior = Enum.ElasticBehavior.WhenScrollable` | | `overscroll-none` | `ElasticBehavior = Enum.ElasticBehavior.Never` | `overscroll-*` is meaningful only on `scrollingframe`. Unresolvable values are `unsupported-pointer-events-value` and `unsupported-overscroll-value`. ## Visibility | Class | Roblox target | | --- | --- | | `hidden` | `Visible = false` | | `visible` | `Visible = true` | ## Overflow | Class | Roblox target | | --- | --- | | `overflow-hidden` | `ClipsDescendants = true` | | `overflow-clip` | `ClipsDescendants = true` | | `overflow-visible` | `ClipsDescendants = false` | Any other `overflow-*` value gives `unsupported-overflow-value`. There is no `overflow-auto` and no `overflow-scroll` — use a `scrollingframe` and the [scrolling families](#scrolling). ## Scrolling Four families, all meaningful only on `scrollingframe`. | Class | Values | Roblox target | Notes | | --- | --- | --- | --- | | `scroll-{x,y,xy}` | — | `ScrollingDirection = X, Y, XY` | | | `scroll-none` | — | `ScrollingEnabled = false` | Does not set a direction | | `scrollbar-w-{n}` | spacing scale, arbitrary | `ScrollBarThickness` | `scrollbar-w-2` is 8px | | `scrollbar-none` | — | `ScrollBarThickness = 0` | | | `scrollbar-{color}` | any color, `/N` modifier | `ScrollBarImageColor3`, `ScrollBarImageTransparency` | | | `canvas-{auto,auto-x,auto-y,none}` | — | `AutomaticCanvasSize = XY, X, Y, None` | | ```tsx title="A vertical list that grows its canvas" <scrollingframe className="scroll-y canvas-auto-y scrollbar-w-2 scrollbar-zinc-600/50" /> ``` ```tsx title="lowers to" <scrollingframe ScrollingDirection={Enum.ScrollingDirection.Y} ScrollBarThickness={8} ScrollBarImageTransparency={0.5} ScrollBarImageColor3={Color3.fromRGB(82, 82, 92)} AutomaticCanvasSize={Enum.AutomaticSize.Y} /> ``` `canvas-*` is what makes a `scrollingframe` measure its own content. Without it, the canvas stays at whatever `CanvasSize` the element declares and the scrollbar has nothing to travel over. **Tailwind's own `scroll-*` utilities are a different family** and are not implemented. `scroll-smooth`, `scroll-m-*` and friends report `unsupported-scroll-value`, naming the values that do work. > **The scrolling families are not host-checked at compile time** > > Like the text families, these are enforced by the editor and the runtime path, not by the static lowering. `<frame className="scroll-y" />` emits `ScrollingDirection` onto a `Frame`, and Roblox rejects it at runtime. See [supported elements](#supported-elements). ## Variants Variants chain with colons, and multiple prefixes on one token combine with AND — `md:touch:w-full` applies only when both conditions hold. An unrecognized prefix reports `unknown-variant`, names the supported set, and drops the token. | Variant | Condition | Notes | | --- | --- | --- | | `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | Viewport width ≥ 640, 768, 1024, 1280, 1536 | `xl` and `2xl` since 0.13.0. Every threshold is a `theme.screens` key | | `max-sm:`, `max-md:`, `max-lg:`, `max-xl:`, `max-2xl:` | Viewport width < the same threshold | Since 0.13.0. The exact complement of the bare form, so the two cover every viewport once. Chains, as `md:max-lg:` | | `attr-[{Name}={value}]:` | The styled instance carries that Roblox attribute | Since 0.13.0. `addVariant()` registers the same condition under a name | | `portrait:` | Viewport width less than height | | | `landscape:` | Viewport width ≥ height | | | `touch:` | `UserInputService.TouchEnabled`, and no gamepad | | | `mouse:` | Neither gamepad nor touch | | | `gamepad:` | `UserInputService.GamepadEnabled` | | | `hover:` | The pointer is over this element | | | `active:` | This element is being pressed | | | `focus:` | This element holds focus or selection | | | `dark:` | `Players.LocalPlayer` carries `VelaColorScheme = "dark"` | | **Breakpoints are configurable.** `theme.screens` is a theme axis like `colors`, so `tablet:` and `max-tablet:` are two lines of config. A `max-` form is the exact complement of the bare one, minimum inclusive and maximum exclusive, so the two cover every viewport once. They chain into a single bucket as `md:max-lg:`. A `max-` in front of something that is not a breakpoint is `unknown-breakpoint`. A chain whose bounds leave no viewport is `invalid-breakpoint-range`. **State variants are yours to define.** `attr-[State=open]:` reads a Roblox attribute off the styled instance. `addVariant("open", { attribute: "State", equals: "open" })` registers the same condition under a name. An `attr-[…]` that does not parse is `malformed-attribute-variant`. See [Configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#addvariant). A square viewport counts as `landscape`. Input mode resolves by priority: gamepad over touch, touch over mouse. Exactly one of `touch`, `mouse` and `gamepad` is ever active. `hover` and `active` are tracked per element, composed with any handlers you attached yourself. `active` clears on `MouseLeave`, since a release outside the element never reaches it. `focus` follows `Focused`/`FocusLost` on a `textbox` and `SelectionGained`/`SelectionLost` elsewhere. `dark` reads an attribute your app owns — Roblox exposes no color scheme to a running game. There are still no structural variants (`first:`, `last:`) and no `disabled:`. Any variant-prefixed token forces the runtime helper into the module, even inside a plain string literal. See [Responsive and input variants](https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants.md) for the cost model and for how to drive `dark:`. ## Not implemented Two diagnostics cover the ground Vela does not. A family it has never heard of is `unsupported-utility-family` — that bucket includes your typos. A real Tailwind family that **cannot** be expressed in Roblox UI is `no-roblox-equivalent`, and that list is permanent by design: | Family | Why there is no equivalent | |---|---| | `tracking-*`, `indent-*`, `break-*`, `hyphens-*`, `list-*` | The Roblox text engine exposes no letter-spacing, indent, or line-breaking control | | `decoration-*`, `overline` | No RichText equivalent (`underline` and `line-through` do exist) | | `blur-*`, `backdrop-*`, `grayscale`, `invert`, `sepia`, `contrast-*` | No per-element filters — `BlurEffect` is camera-global | | `brightness-*`, `fill-*`, `stroke-*` | Deliberately excluded; an `ImageColor3` approximation would mislead | | `skew-*`, `perspective-*` | Roblox UI is strictly 2D | | `static`, `fixed`, `absolute`, `relative`, `sticky`, `block`, `inline` | Roblox positioning is always parent-relative; there is no positioning model to switch | | `float`, `clear`, `columns-*`, `col-span-*`, `row-span-*` | `UIGridLayout` has no span or flow concept | | `cursor-*`, `caret-*`, `accent-*`, `select-*`, `snap-*`, `resize-*` | No property to target | | Tailwind's `scroll-*` (`scroll-smooth`, `scroll-m-*`) | The prefix is taken by the [scrolling families](#scrolling); reports `unsupported-scroll-value` | | `ms-*`, `me-*` | No logical-direction margins | | `animate-ping` | Needs a cloned element; the other `animate-*` presets exist | Families that exist in Vela but reject a given payload each have their own diagnostic — see the sections above and [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md). ## Dynamic class names When `className` is a dynamic expression rather than something Vela can collapse at compile time, the class string is resolved by the injected runtime helper instead. **Every family on this page resolves there**, with the static path's semantics. Opacity modifiers, arbitrary values, and the families that only meet at the end. That last group is the two `Size` axes, `Position` and `AnchorPoint`, `FontFace`, and a grid track with the gap it gives back. One thing differs, and it is the runtime path being stricter rather than thinner. **A utility the host element cannot carry is dropped** instead of written, since assigning `TextColor3` to a `Frame` is a hard Roblox error. The static path writes it anyway. See [supported elements](#supported-elements). `opacity-*` meets the two paths halfway. The compiler leaves the whole class list to the host, which resolves it and hands the children one alpha. An `opacity-*` written inside a variant recipe reaches the subtree it is written over. Read [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) for the cost model and for what promotes an element onto this path. ## See also - [Configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md) for the theme keys these utilities look up, and [`plugins`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#plugins) for registering utilities of your own. - [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) for every warning code named on this page. - [Recipes](https://docs.astra-void.xyz/vela-rbxts/guides/recipes.md) for these utilities composed into real interface pieces. --- # Cheat sheet > Every utility class on one filterable page — by class, by property, or by lowering path. Source: https://docs.astra-void.xyz/vela-rbxts/reference/cheat-sheet/ The same classes the [utility reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md) documents, with the prose taken out. Filter by a class prefix such as `bg-`, by the Roblox property a class writes such as `UIStroke` or `TextSize`, or by the family name. Every family heading links back to the section that explains it. The **runtime** pill marks a utility that builds a wrapper, a separator or a loop, which no static prop can express. Margins, `divide-*`, `animate-*` and every variant prefix move the element onto the [runtime path](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#the-two-lowering-paths), even inside a plain string literal. Filter to just those to see what a class list is costing you. _Every utility class on one filterable page. The same rows, with the prose that explains them, are in the Utility reference._ A class that is not here is one Vela does not compile. The reference page lists [what has no Roblox equivalent](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#not-implemented) and why, and [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) explains every warning code named above. Three things this page cannot flatten into rows, all on the reference. [Color keys and the `/N` modifier](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#color), [arbitrary bracket values](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#arbitrary-values), and [which utilities each host element accepts](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#supported-elements). --- # Configuration > How vela.config.ts is found and loaded, the complete theme schema, the built-in defaults, and the tsconfig plugin options. Source: https://docs.astra-void.xyz/vela-rbxts/reference/config/ Vela reads one optional config file, covering the theme, whether [preflight](#preflight) neutralizes the Roblox host defaults, and which UI [`framework`](#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: ```json title="vela.config.json" { "$schema": "./node_modules/vela-rbxts/schema.json", "theme": { "extend": { "colors": { "brand": { "500": "Color3.fromRGB(99, 102, 241)" } } } } } ``` > **Resolution runs per file; the load runs once per build** > > The walk up from each source file runs per file, which is what lets two directories carry different configs. The load is resolved once per directory per build and re-read only when the file changes. A config that throws is cached too, so a typo is reported once and the next edit lifts it. Keep the file cheap regardless: it is executed as real code. ## 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. ```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)", }, }, }, }, }); ``` ## 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. | Prop | Type | Description | | --- | --- | --- | | `presets` | `readonly (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. | | `preflight` | `boolean` | Default 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.colors` | `Record<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.radius` | `Record<string, string>` | Replaces the entire radius scale. Each value is a roblox-ts expression producing a UDim. | | `theme.spacing` | `Record<string, string>` | Replaces the entire spacing scale. Each value is a roblox-ts expression producing a UDim. | | `theme.fontFamily` | `Record<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.screens` | `Record<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". | | `plugins` | `VelaPlugin[] \| { 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.colors` | `Record<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.radius` | `Record<string, string>` | Shallow-merges over the default radius scale by key. | | `theme.extend.spacing` | `Record<string, string>` | Shallow-merges over the default spacing scale by key. | | `theme.extend.fontFamily` | `Record<string, string>` | Shallow-merges over the default font family scale by key. | | `theme.extend.screens` | `Record<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](https://centau.github.io/vide/). Most projects never write it: left unset, the target is inferred from `tsconfig.json`. ```ts title="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](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) 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. ```tsx <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**. ```ts title="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. ```ts title="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](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#values-are-source-code-not-values) has the three shapes side by side. > **The runtime path re-parses theme values from text** > > The splice above is the **static** path only. A class resolved at runtime carries your theme as serialized text. 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**, with no diagnostic, on dynamically classed elements only. ## 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. > **theme.colors silently discards theme.extend.colors** > > If `theme.colors` is present, `theme.extend.colors` is **not applied at all** — it is dropped > without warning, and every built-in palette is dropped with it. This diverges from real Tailwind, > where `extend` is layered on top of a replaced `colors`. > > A config with `theme.colors.surface` and `theme.extend.colors.brand` resolves to a theme containing > only `surface` — neither `brand-500` nor `slate-700` resolves. > > Add colours with `theme.extend.colors` alone. **Every axis behaves this way** — a top-level `theme.radius`, `theme.spacing` or `theme.fontFamily` discards both the defaults and its own `extend` counterpart in the same silence. ## 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. | Key | Value | |---|---| | `DEFAULT` | `new UDim(0, 4)` | | `none` | `new UDim(0, 0)` | | `xs` | `new UDim(0, 2)` | | `sm` | `new UDim(0, 4)` | | `md` | `new UDim(0, 6)` | | `lg` | `new UDim(0, 8)` | | `xl` | `new UDim(0, 12)` | | `2xl` | `new UDim(0, 16)` | | `3xl` | `new UDim(0, 24)` | | `4xl` | `new UDim(0, 32)` | | `full` | `new 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. | Key | Value | |---|---| | `sans` | `rbxasset://fonts/families/SourceSansPro.json` | | `serif` | `rbxasset://fonts/families/Merriweather.json` | | `mono` | `rbxasset://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: ```ts title="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. | Key | Default | |---|---| | `sm` | `640` | | `md` | `768` | | `lg` | `1024` | | `xl` | `1280` | | `2xl` | `1536` | Every key gives two prefixes, a minimum width and its `max-` complement, so one entry is all a custom breakpoint takes: ```ts title="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](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem). | Key | Default | What it is | |---|---|---| | `base` | `16` | Pixels per rem at `baseResolution` | | `min` | `8` | Lower clamp, in pixels | | `max` | `64` | Upper 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 | ```ts title="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. ```ts title="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**. | Form | Example | Meaning | |---|---|---| | Class list | `btn: "bg-blue-600 rounded-lg px-4"` | Expands to those utilities | | Property map | `panel: { BorderSizePixel: "0" }` | Assigns those properties directly | Property-map values follow the same rule as theme values: they are [roblox-ts expression strings](#values-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. > **A cycle expands to nothing, silently** > > The depth cap that stops `{ a: "b", b: "a" }` recursing terminates the expansion rather than reporting it. The element comes out carrying none of the utilities the cycle named. If a plugin utility does nothing at all, check whether it reaches back into itself. ### 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: ```ts title="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-*`](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#motion). ```ts title="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: ```ts title="@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: ```json title="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. ```ts title="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: ```ts title="@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. | Prop | Type | Description | | --- | --- | --- | | `filter.skipNodeModules` | `boolean` | Default true. Skip any file whose path contains a node_modules segment. | | `filter.requireClassName` | `boolean` | Default true. Skip any file whose source text does not contain the substring className. | | `filter.requireJsxSyntax` | `boolean` | Default true. Skip any file whose source text does not match an opening-JSX-tag pattern. | | `diagnosticCodeBase` | `number` | Default 89000. The first numeric diagnostic code; each diagnostic in a file gets base + index. | | `projectRoot` | `string` | Defaults to the program's current directory. Currently inert — nothing resolves paths from it; discovery walks up from each file being compiled. | | `config` | `TailwindConfig` | An 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. | ```json title="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](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md) for a working walkthrough of extending the theme. - [Utility reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md) for the classes that read these theme keys. - [API](https://docs.astra-void.xyz/vela-rbxts/reference/api.md) for `defineConfig` and `defaultConfig`. --- # CLI > The `vela` command — lowering a source tree ahead of `rbxtsc`, every flag, and what it costs. Source: https://docs.astra-void.xyz/vela-rbxts/reference/cli/ Since **0.11.0**, `vela-rbxts` installs a binary. `vela build` lowers your `className` usage into a second source tree and `rbxtsc` compiles *that*, with no transformer plugin registered at all. ```bash npx vela build ``` This is an **alternative** to [the transformer](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md#choose-how-vela-runs), not an addition to it. Both paths call the same compiler through the same host adapter, so the lowered output is the same either way. What changes is when it happens, and what `rbxtsc` sees. Reach for the CLI when a project cannot register a roblox-ts transform plugin. A pinned toolchain, a build system that drives `tsc` itself, or a CI step that wants the lowered sources as a reviewable artifact. Nothing else about Vela changes. `vela.config.ts`, the [ambient declaration](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md#declare-the-classname-prop), the classes you write, and every diagnostic are identical on both paths. The package installs the command under two names, `vela` and `vela-rbxts`, which do the same thing. ## Commands | Command | What it does | | --- | --- | | [`build`](#build) | Transform the source tree once, then exit | | [`watch`](#watch) | Transform once, then re-transform on change | Running `vela` with no command prints help and exits `0`. ## Options Both commands take the same flags. | Option | | | --- | --- | | `-p, --project <dir>` | Project root. Default: the current directory | | `--src <dir>` | Source tree to read, resolved against the project root. Default: `src` | | `--out <dir>` | Generated tree to write. Default: `.vela/src` | | `--clean` | Delete the generated tree before building | | `-q, --quiet` | Suppress the header and the summary line | | `-h, --help` | | | `-v, --version` | Print the `vela-rbxts` version | Value flags take either form: `--src ui` and `--src=ui` are the same. `--src` and `--out` may not be the same directory, may not contain each other, and `--out` may not be the project root. Each is rejected before anything is written: ```text vela: error: --out must differ from --src: the generated tree would overwrite your sources. ``` > **--quiet is not --silent** > > `-q` drops the two informational lines — the `src -> .vela/src` header and the > `1 transformed, 2 copied` summary. Setup warnings and compiler diagnostics still print, because they > go to stderr and they are the reason to run the thing. There is no flag that silences those. ## Switching from the transformer Two edits to `tsconfig.json`. Point `rootDir` and `include` at the generated tree, and drop the plugin entry: ```json title="tsconfig.json" { "compilerOptions": { "rootDir": ".vela/src", "baseUrl": ".vela/src", "outDir": "out" }, "include": [".vela/src"] } ``` Leaving the plugin registered means both paths run and the second finds nothing left to lower — wasted work rather than broken output. `vela build` checks for both mistakes on every run and names them: ```text vela: warning: tsconfig.json compilerOptions.rootDir is "src" but the generated tree is ".vela/src". Point rootDir and include at ".vela/src" so rbxtsc compiles the lowered sources. vela: warning: tsconfig.json still registers the "vela-rbxts/transformer" plugin. Remove it — the CLI already lowered these sources. ``` The check reads `tsconfig.json` at the project root, tolerating comments and trailing commas. It is a hint, not validation. A file it cannot parse produces no warnings rather than an error, and `include` is not inspected at all. Only `rootDir` and `plugins` are. Your build becomes two steps. `vela build` exits non-zero when a file fails to compile, so `&&` is the right joint: ```bash title="Build" npx vela build && npx rbxtsc -p tsconfig.json ``` ```bash title="Watch" npx vela watch & npx rbxtsc -w -p tsconfig.json ``` > **TypeScript 7 breaks this too** > > The CLI loads `vela.config.ts` through the same host adapter the transformer uses, so it fails the same way on TypeScript 7. An error on the first file, naming neither TypeScript nor a version: > > ```text > client/App.tsx - error vela/host(compiler-invocation-failed): Cannot read properties of undefined (reading 'Latest') > ``` > > Pin `typescript@5.9.3`, as [installation](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md#install-the-packages) > covers. A `vela.config.json` sidesteps the transpile entirely. ## What lands in the generated tree `--out` is a **mirror** of `--src`, not a subset of it: - **`.tsx` files are transformed.** A file that uses no `className` passes through unchanged and counts as copied. - **Everything else is copied byte for byte** — `.ts`, `.d.ts`, `.lua`, `.json`, assets, anything. Your `vela-env.d.ts` rides along, so the `className` augmentation still loads from inside the tree `rbxtsc` compiles. - **Directory structure is preserved**, so relative imports and `baseUrl` resolution work unchanged. Two bookkeeping files sit beside it, both under `.vela/` and neither inside the mirror: | File | | | --- | --- | | `.vela/.gitignore` | Written on first run, containing `*`, so the generated tree stays out of version control. An existing one is never rewritten | | `.vela/build-manifest.json` | What the last run emitted — the list that drives pruning | > **Edit src, never the generated tree** > > The mirror is a build output. An edit inside it survives exactly until the next run touches that file, > and there is no diagnostic when it disappears. ## Rebuilds and pruning **An unchanged output is never rewritten.** The CLI compares what it is about to write against what is on disk, and skips the write when they match. That keeps the file's mtime. Without this, every `vela build` would touch the whole tree and `rbxtsc -w` would rebuild all of it. **Pruning is driven by the manifest, not by the directory.** When a source file disappears, the CLI deletes its counterpart and removes any directory that emptied out. It only does this for paths an earlier run recorded as its own. A file you put in the generated tree by hand is never deleted, which is the safe failure mode for a flag pointed somewhere it should not have been. `--clean` does not honor that distinction. It removes the `--out` directory outright and resets the manifest, so anything else living there goes with it. ## Diagnostics and exit codes Diagnostics are the compiler's, printed to stderr and anchored to your real sources. The file, line and column are the ones you wrote, not positions in the generated tree: ```text client/App.tsx:7:29 - warning vela/compiler(unknown-variant): Unknown variant "checked" in "checked:px-4"; supported variants are sm, md, lg, portrait, landscape, touch, mouse, gamepad, hover, active, focus, and dark. 1 transformed, 2 copied, 2 warnings (381ms) ``` Paths are relative to `--src`, so the leading `src/` you would see in `rbxtsc` output is absent here. The summary counts files transformed and copied, plus removed files, errors and warnings when any are non-zero. | Exit code | | | --- | --- | | `0` | Built. Warnings do not fail the run | | `1` | A file failed to compile, or `--src` does not exist | | `2` | Bad usage — unknown command, unknown option, a flag missing its value, or a `--src`/`--out` conflict | Every diagnostic keeps the code it has under the transformer, so [the diagnostics reference](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) applies unchanged. Only the presentation differs: `vela/compiler(unknown-variant)` here against roblox-ts's `TS89000: [@vela-rbxts/compiler] unknown-variant` there, because the CLI is not routing through roblox-ts's numbered diagnostics. ## `build` ```bash npx vela build [options] ``` Walks `--src`, emits every file, then drops whatever the manifest lists that no longer has a source. ## `watch` ```bash npx vela watch [options] ``` Builds everything once, prints `vela watch: waiting for changes...`, then rebuilds incrementally. Changes are debounced 60ms and the summary is prefixed: ```text vela watch: 1 transformed, 0 copied (26ms) ``` A **deleted** source prunes its counterpart. An **edited `vela.config.ts` or `vela.config.json` rebuilds the whole tree**, clearing the host's config cache first. A theme edit reaches every file that reads it, rather than only the next file you touch. Only the project root is watched for those two names. Watching uses recursive `fs.watch`. Where the platform does not support it, the CLI says so and falls back to polling every 400ms: ```text vela: warning: Recursive file watching is unavailable; polling every 400ms instead. ``` `SIGINT` and `SIGTERM` stop the watchers and exit with the status of the last build. ## What you give up Two consequences are worth knowing before you switch. **Your editor and `tsc --noEmit` see the generated tree.** That is what `rootDir` and `include` name, so type errors, stack traces and go-to-definition land in `.vela/src`. The [language server](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md) is unaffected — it reads your real sources, so completions, hover and colors on `className` are exactly as they were. **It is a second thing to keep running.** The transformer cannot be out of date with your sources because it runs inside the build. `vela watch` can — if it dies, `rbxtsc` keeps happily compiling the last tree it wrote. ## Related - [Installation](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md) — the transformer path, and the TypeScript pin both paths need. - [Configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md) — `vela.config.ts`, which the CLI reads identically. - [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) — every code either path can report. --- # Diagnostics > Every diagnostic code, how it reaches your build or editor, and the ones easy to miss. Source: https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics/ On the compile-time path, Vela reports most of what it cannot lower. This page lists every code it produces, what to do about the ones you will actually hit, and where nothing is reported at all. ## How diagnostics reach you The compiler produces diagnostics. The host adapter maps them into rbxtsc's diagnostic list, using this message format: ```text [@vela-rbxts/compiler] unknown-theme-key: Unknown theme key "brand-500" for background color utility in className literal. ``` The bracketed prefix is the phase: `@vela-rbxts/compiler` for anything found in your classes, `@vela-rbxts/rbxtsc-host` for the one diagnostic the adapter synthesizes. Numeric codes are `diagnosticCodeBase + index` per file, the base defaulting to `89000`, so the numbering restarts for each file. The category comes from the diagnostic's level and defaults to Warning. > **Two things that will surprise you** > > **Diagnostics can be dropped entirely.** They are delivered through `context.addDiagnostic`. On a roblox-ts that does not expose it, every Vela diagnostic is discarded, and your build looks clean while classes go missing. If you have never seen a Vela warning, verify with `className="bg-nope-500"`. > > **Some spans are guessed, and some are not spans at all.** A diagnostic carries a real source range > wherever the compiler could anchor it. That covers every token inside a static string `className`. > `tsx-parse-failed` is anchored too, and names the line and column. > > With no range, the host adapter searches for the first textual occurrence of the diagnostic's token. > That only happens when the diagnostic carries one, which in practice is the dynamic-`className` > case. Everything else falls through to `{start: 0, length: 0}` at the top of the file. > `classname-on-unsupported-host` is a middle case: a real range for a plain string literal, offset 0 > for a computed expression. > > So an underline can land on an unrelated comment, or on the first character of the file. Read the message rather than the underline. ## Code table Every code is a warning unless marked otherwise. ### Families, variants, and payload shapes | Code | Fires when | |---|---| | `unsupported-utility-family` | The token does not match any known utility family — including typos | | `no-roblox-equivalent` | A real Tailwind family that cannot be expressed in Roblox UI (`tracking-*`, `blur-*`, `cursor-*`, …); the message names the family | | `unknown-variant` | A colon prefix that is not a supported variant; the message lists the set, the configured breakpoints, and the `attr-[…]` form | | `unknown-breakpoint` | Since 0.13.0: a `max-` in front of something that is not a breakpoint. The message names the configured ones and points at `theme.screens` | | `invalid-breakpoint-range` | Since 0.13.0: a chain whose width bounds leave no viewport, as `lg:max-md:` or `md:max-md:`. Emitting it would produce a rule that never fires | | `malformed-attribute-variant` | Since 0.13.0: an `attr-[…]` that does not parse — brackets left open, or no value to compare against. The message names which | | `unsupported-arbitrary-value` | A `[...]` payload the family cannot read — `w-[abc]`, `bg-[notacolor]`, `font-[600]`. Since 0.7.0 most length families *do* read brackets, and since 0.12.3 a space inside them does not split the token, so `w-[calc(100% - 4px)]` reports this once rather than three times; see [arbitrary values](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#arbitrary-values) | | `unsupported-opacity-modifier` | A `/N` opacity modifier on a family with no transparency channel. Since 0.12.0 that is `placeholder-*` alone, and the message says why | ### Theme and color | Code | Fires when | |---|---| | `unknown-theme-key` | A color, radius, spacing, or font-family key is absent from the theme | | `unsupported-color-key` | `current` or `inherit`, or `transparent` on a property with no transparency channel | | `color-missing-shade` | A palette family referenced without a shade, and that palette has no `DEFAULT` | | `color-invalid-shade` | A literal given a shade, or a palette missing that shade | | `unsupported-size-spacing-value` | A spacing value used for a size is not offset-only | ### Per-value rejections Each family that accepts a closed set of values has its own code, so the message always tells you which family rejected what: | Code | Family | |---|---| | `unsupported-border-value` | `border-*` styles, sides, and off-list thicknesses. A `/N` modifier is no longer among them — since 0.12.0 `border-slate-500/25` resolves | | `unsupported-stroke-value` | `ring-*` / `outline-*` off-list values, `inset`, `offset-*` | | `unsupported-rotation-value` | `rotate-*` outside the degree list | | `unsupported-scale-value` | `scale-*` outside the value list | | `unsupported-opacity-value` | `opacity-*` not an integer 0–100 | | `unsupported-aspect-value` | `aspect-*` outside `square`, `video`, valid brackets | | `unsupported-flex-direction` | `flex-*` that is neither a direction nor a flex-item keyword | | `unsupported-alignment-value` | An unresolvable `justify-*`, `items-*`, or `content-*` | | `unsupported-grid-value` | A `grid-cols-*`/`grid-rows-*` count outside 1–12 | | `unsupported-layout-order-value` | An unresolvable `order-*` | | `unsupported-space-value` | An unresolvable `space-x-*`/`space-y-*` | | `unsupported-margin-value` | An unresolvable margin payload | | `unsupported-negative-margin` | `-mr-*` or `-mb-*` — only top/left margins can be negative. Since 0.12.4 a side holds one signed value and the last class written to it wins; see [margin](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#margin) | | `unsupported-divide-value` | An unresolvable `divide-*` payload | | `unsupported-line-height-value` | A `leading-*` outside the six named keys | | `unsupported-text-size` | A `text-{size}` that cannot be resolved (not reachable in practice) | | `unsupported-font-weight` | `font-{x}` is not a known weight name (not reachable since 0.7.0 — see below) | | `unsupported-text-alignment` | `text-justify`, or an `align-*` outside top/middle/bottom | | `unsupported-whitespace-value` | A `whitespace-*` outside `normal`/`nowrap` | | `unsupported-gradient-direction` | `bg-gradient-to-{x}` with a direction outside the eight | | `unsupported-shadow-inset` | `shadow-inner` | | `unsupported-overflow-value` | `overflow-*` other than `hidden`, `clip`, `visible` | | `unsupported-anchor-value` | `origin-{x}` outside the nine origin keys | | `unsupported-object-fit-value` | An `object-*` outside `cover`/`contain`/`fill`/`tile` | | `unsupported-pointer-events-value` | A `pointer-events-*` outside `none`/`auto` | | `unsupported-overscroll-value` | An `overscroll-*` outside `auto`/`contain`/`none` | | `unsupported-scroll-value` | A `scroll-*` outside `x`/`y`/`xy`/`none` — which is where Tailwind's `scroll-smooth` and `scroll-m-*` land | | `unsupported-transition-value` | An unresolvable transition/duration/delay/ease payload. `transition-shadow` stopped landing here in 0.13.0, when transitions began moving the helper instances | | `unsupported-animation-value` | An `animate-*` outside the presets | Z-index gets four codes of its own: `z-auto` is `unsupported-z-index-auto`, a negative `-z-N` is `unsupported-negative-z-index`, a fractional `z-[1.5]` is `unsupported-arbitrary-z-index`, and any other unbracketed number is `unsupported-z-index-value`. A whole-number `z-[15]` resolves. **`font-*` falls through to `unknown-theme-key`.** The prefix resolves the fixed weight names first and reads anything else as a key in [`theme.fontFamily`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#fontfamily). `font-nope`, `font-600` and `font-extrablack` all report `unknown-theme-key`. `unsupported-font-weight` still exists in the code table, but no payload reaches it. ### Contextual warnings | Code | Fires when | |---|---| | `transition-without-runtime` | `transition`/`duration-*`/`ease-*`/`delay-*` on an element with no variant rules and no dynamic `className` — there is nothing to animate, so the tokens are dropped | | `motion-on-component` | `transition-*` or `animate-*` on a component element — motion needs a real instance to drive | | `decoration-on-richtext` | `underline`/`line-through` on an element that already sets `RichText` itself — Vela backs off rather than double-wrapping your markup | | `classname-on-unsupported-host` | **Build only.** `className` sits on a JSX element that is neither a supported host element nor a component | | `unsupported-host-utility` | **Editor only.** The utility is not allowed on that host element tag | ### Errors | Code | Fires when | |---|---| | `tsx-parse-failed` | The file failed to parse, or parsed with recovered errors | | `tsx-emit-failed` | Code generation failed after a successful transform | | `invalid-config-json` | The resolved config JSON handed to the compiler is malformed; the file falls back to the default theme | | `compiler-invocation-failed` | The native compiler call threw; synthesized by the host adapter | The errors are structural rather than about your classes. `compiler-invocation-failed` has one cause common enough to name. The host adapter resolves `typescript` from your project and calls into its API. TypeScript 7 surfaces here as `Cannot read properties of undefined (reading 'Latest')` on the first file. Pin `typescript@5.9.3`. Everything else under this code is the native binding failing to load or throwing. `classname-on-unsupported-host` is the one that tells you nothing happened. Vela lowers `className` on the eight supported host elements and on components. Anything else keeps the attribute verbatim and warns: ```tsx title="Warns, and the className survives untouched" <screengui className="bg-slate-700" /> ``` That bucket is everything neither host nor component — lowercase intrinsics such as `screengui`, and namespaced tags such as `<svg:rect />`, which never count as components. The message names only the local part, so `<svg:rect />` is reported as `rect`. Move the classes onto a `frame` inside the element, or onto a component that forwards them. This one is **build only**: the editor surface never collects an unsupported element, so it never emits this code. `unsupported-host-utility` never appears in a build. The per-element restrictions it reports are checked by the editor surface alone. `<frame className="text-red-500" />` compiles and emits a `TextColor3` assignment on a Frame with no complaint. Treat it as an editor-only lint. It is never raised on a component either, since the host element behind `<Panel />` is unknown. The **runtime path** does enforce the rule, silently. A utility the host element cannot carry is dropped rather than written, since the assignment would be a hard Roblox error. The same class list behaves differently on the two paths, and neither reports anything at build time. ## The ones you will actually hit ### unsupported-utility-family A class Vela does not recognize at all — usually a typo. The token is dropped and the element compiles normally. ```tsx title="A typo" <frame className="shadwo-lg rounded-lg" /> ``` `rounded-lg` is applied, and `shadwo-lg` is dropped with a warning. The same code covers typos and genuinely unknown families, so read the token before concluding a feature is missing. ### no-roblox-equivalent You wrote a real Tailwind utility that Roblox UI cannot express. The message names the family and says the token is ignored: ```text title="Compiler output" [@vela-rbxts/compiler] no-roblox-equivalent: Tailwind "tracking" utilities have no Roblox equivalent, so "tracking-wide" is ignored. ``` This is a design decision, not a roadmap gap — the [not-implemented table](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#not-implemented) gives the reason per family and what to reach for instead. ### unknown-theme-key The class parsed fine, but the key it looked up is not in the resolved theme. The family in the message tells you which scale was searched: `colors`, `radius`, or `spacing`. ```tsx title="Fails" <frame className="rounded-huge p-0.25" /> ``` `rounded-huge` fails because `rounded-*` is a pure theme lookup and `huge` is not one of the default radius keys — add it under `theme.extend.radius` or use `4xl`. `p-0.25` fails because the spacing fallback only accepts multiples of `0.5`. Use `p-0.5`. The most common form of this one is subtler. A color you thought you defined in `theme.extend.colors` will report `unknown-theme-key` if your config also sets a top-level `theme.colors`, because that combination silently discards `extend`. See the callout in [Configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#merge-semantics). ### color-missing-shade You named a palette family without a shade, and that palette has no `DEFAULT` to fall back on. ```tsx title="Fails, if your brand palette defines only 500 and 700" <frame className="bg-brand" /> ``` ```text title="Compiler output" [@vela-rbxts/compiler] color-missing-shade: Color palette "brand" for background color utility has no "DEFAULT" shade, so it requires an explicit shade such as "brand-500" in className literal. ``` Either write the shade, as `bg-brand-500`, or add a `DEFAULT` key to the palette in `theme.extend.colors` so the bare form resolves. Literal colours are always usable without a shade. That covers `black`, `white`, and anything you define as a plain string. Every built-in palette ships a `DEFAULT`, so `bg-slate` works out of the box. ### color-invalid-shade Either you gave a literal a shade, or you asked a palette for a shade it does not have. ```tsx title="Fails" <frame className="bg-white-500 text-brand-400" /> ``` `bg-white-500` fails because `white` is a literal — write `bg-white`. `text-brand-400` fails if your `brand` palette only defines `500` and `700` — add `400`, or use a shade you defined. Palettes do not interpolate missing shades. ## Editor-only filtering The editor surface applies one filter the build does not. It suppresses `unknown-theme-key` while the payload you have typed so far is still a **prefix of a real theme key**. As you type `bg-sla` on the way to `bg-slate-800`, no squiggle flashes, because `sla` can still become `slate`. `bg-nope` warns immediately, because no theme key starts with `nope`. A wrong key that happens to be a prefix of a real one, such as `bg-sl` or `bg-slat`, looks fine in the editor. It still warns in the build. Codes other than `unknown-theme-key` are never filtered, so `color-missing-shade` on a `DEFAULT`-less palette shows up immediately. ## See also - [Utility reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md) for the values each family accepts. - [Editor setup](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md) for getting these into your editor. - [Troubleshooting](https://docs.astra-void.xyz/vela-rbxts/guides/troubleshooting.md) for the failures that produce no diagnostic at all. --- # API > Everything importable from `vela-rbxts`, the published packages, and the versions it is tested against. Source: https://docs.astra-void.xyz/vela-rbxts/reference/api/ Vela's import surface is small. One package has a public entry point, that entry point exports three values and four types, and a second subpath exports the transformer you wire into `tsconfig.json`. ```bash pnpm add vela-rbxts ``` ## From `vela-rbxts` ```ts import { defaultConfig, defineConfig, definePreset, plugin, createTransformer, type TailwindConfig, type TailwindConfigInput, type VelaPreset, type ThemeScreens, type VariantDefinition, type ClassValue, type StylableProps, } from "vela-rbxts"; ``` The plugin and variant types are re-exports of `@vela-rbxts/config`, so nothing needs to be installed to reach them. ### defineConfig ```ts function defineConfig(input?: TailwindConfigInput): TailwindConfig; ``` Takes an input-shaped theme object and resolves it against the built-in defaults. The argument is optional, and `defineConfig()` returns a fresh object deep-equal to `defaultConfig`. It is injected into `vela.config.ts`'s scope automatically, which is why stripping the import still works. ### defaultConfig ```ts const defaultConfig: TailwindConfig; ``` The resolved built-in theme: 28 colour families, ten named radius keys plus a `DEFAULT`, and a single spacing key. This is what Vela uses when no `vela.config.ts` is found. ### createTransformer ```ts function createTransformer( options?: RbxtscTransformerBridgeOptions, ): RbxtscTransformerBridge; // the returned bridge: // { name, getFileEligibility, shouldTransformFile, transformSource, transformFile } ``` The rbxtsc host bridge, returning an object whose `name` is `"@vela-rbxts/rbxtsc-host"` plus the eligibility predicates and the transform entry point. `transformSource` and `transformFile` are the same function under two names. You do not need this to use Vela — it exists for tooling that drives the transform itself. > **Two different createTransformer functions** > > `createTransformer` imported from `vela-rbxts` is the **rbxtsc host bridge** described above. > > `createTransformer` on `vela-rbxts/transformer` is the **TypeScript program transformer factory**. > It is a completely different function with a different signature and a different job. > > The one that goes in `tsconfig.json` is `vela-rbxts/transformer`. Pass the root package's `createTransformer` to roblox-ts and nothing is transformed, with no error explaining why. ### Types | Prop | Type | Description | | --- | --- | --- | | `TailwindConfig` | `{ theme: { colors: ThemeColors; radius: ThemeScale; spacing: ThemeScale } }` | A fully resolved config — every scale present. What defineConfig returns and what the compiler consumes. | | `TailwindConfigInput` | `{ theme?: { colors?; radius?; spacing?; extend?: { colors?; radius?; spacing? } } }` | The authoring shape — everything optional, with the extend branch. What you pass to defineConfig. | | `VelaPreset` | `TailwindConfigInput` | A shareable slice of configuration. Same shape as what you pass to defineConfig; definePreset() types one without resolving it, so it still merges as an input rather than as a finished config. Added in 0.13.0. | | `ClassValue` | `string \| number \| boolean \| null \| undefined \| Record<string, boolean \| null \| undefined> \| ClassValue[]` | The accepted type of the className prop. Covers conditional dictionary and nested array forms, not just strings. | | `StylableProps` | `StylableProps<P = unknown>` | Intersects your own props P with { className?: ClassValue }. Use it when a component of yours should forward className. | ### The global augmentation Importing `vela-rbxts` has a side effect: it declares ```ts declare global { namespace React { interface Attributes { className?: ClassValue; } } } ``` That augmentation is what makes `className` type-check on the host elements. It applies only once the module is imported somewhere your program includes, typically a one-line `.d.ts`: ```ts title="src/vela-env.d.ts" import "vela-rbxts"; ``` Several helpers exist in `@vela-rbxts/config` but are **not** re-exported from the root. The functions are `SHADES`, `PALETTE_DEFAULT_KEY`, `resolveThemeColors`, `mergeColorRegistry`, `normalizeColorRegistry` and `normalizeColorScale`. The types are `Shade`, `PaletteKey`, `PaletteDefaultKey`, `ThemeScale`, `ColorPalette`, `ColorValue`, `ColorScaleInput`, `ThemeColors`, `ColorInputMap`, `ThemeConfig` and `ThemeConfigInput`. Reach into `@vela-rbxts/config` directly, and expect them to be less stable. ## From `vela-rbxts/transformer` A CommonJS module whose export is a callable program-transformer factory. It also carries `.createTransformer`, `.createRbxtsTailwindProgramTransformer` and `.default`, all pointing at itself, so every resolution style lands on the same function. This is the value that goes in `tsconfig.json`: ```json title="tsconfig.json" { "compilerOptions": { "plugins": [{ "transform": "vela-rbxts/transformer" }] } } ``` The factory throws if roblox-ts does not hand it a TypeScript instance. Plugin options are in [Configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#tsconfig-plugin-options). ## Published packages | Package | What it is | Install directly? | |---|---|---| | `vela-rbxts` | The entry point: config helpers, types, the global augmentation, the transformer subpath, and — since 0.11.0 — the `vela` CLI | **Yes** — this is the only one an app installs | | `@vela-rbxts/compiler` | The Rust compiler behind an N-API binding: transform, completions, hover, diagnostics, document colors | No, pulled in transitively | | `@vela-rbxts/compiler-wasm` | The same compiler built to WebAssembly, for hosts that cannot load the native addon — a browser, most usefully | Only if you are lowering classes outside Node | | `@vela-rbxts/config` | The theme schema, `defineConfig`, `defaultConfig`, and the color merge helpers | Only to reach a helper the root package does not re-export | | `@vela-rbxts/core` | Shared constants, including the supported host element tags | No — and nothing depends on it, so it never arrives transitively either | | `@vela-rbxts/ir` | The intermediate style representation shared between the compiler and its consumers | No | | `@vela-rbxts/types` | Shared TypeScript types | No | | `@vela-rbxts/rbxtsc-host` | The roblox-ts adapter: file eligibility, config discovery, diagnostic mapping | No | | `@rbxts/vela-runtime-core` | The target-neutral half of the runtime: resolution engine, theme normalization, rem math, rich text, margin and divide computation | No, pulled in by whichever host you emit for | | `@rbxts/vela-runtime` | The React runtime host every transformed module imports, as one ModuleScript the place shares | No — it ships with `vela-rbxts` | | `@rbxts/vela-runtime-vide` | The same host for Vide, imported instead when the project sets `framework: "vide"` | No — it ships with `vela-rbxts` | | `@vela-rbxts/lsp` | The language server binary and its platform packages | Only if you wire up a non-VS Code editor yourself | | `vela-rbxts-lsp` | The VS Code extension, published as a VSIX rather than to npm | Install from the marketplace as `astra-void.vela-rbxts-lsp` | For almost every project: install `vela-rbxts`, add the transformer to `tsconfig.json`, install the VS Code extension. Everything else arrives as a dependency. The three `@rbxts/vela-runtime*` packages are the exception to "nothing of Vela's reaches your place". They sit under the `@rbxts` scope because roblox-ts only resolves a package whose scope directory is one of the project's `typeRoots`. That is the one every roblox-ts project lists and every Rojo template maps. So they need no tsconfig or Rojo change. But pnpm needs `nodeLinker: hoisted` for the directory to be real, [covered in installation](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md#install-the-packages). Each host declares its own UI library as an **optional** peer, which keeps a Vide project from installing React. The host you do not emit for is inert. The root package also declares two `bin` entries, `vela` and `vela-rbxts`, pointing at the same CLI. It lowers a source tree ahead of `rbxtsc` for projects that cannot register the transformer — see [the CLI reference](https://docs.astra-void.xyz/vela-rbxts/reference/cli.md). All packages are versioned in lockstep at **0.13.0**, the VS Code extension included. Never mix versions across the `@vela-rbxts/*` scope. What changed in each release is in the [release notes](https://docs.astra-void.xyz/vela-rbxts/reference/release-notes.md). Three subpaths beyond the roots are public. `@vela-rbxts/rbxtsc-host/project-config` exposes config discovery and loading, which the VS Code extension uses to evaluate the config files it watches. `@vela-rbxts/compiler/native` exposes the raw N-API binding. Both roots also declare `"./package.json"` in their exports maps. The root package ships a `schema.json` describing the config input shape — point a `vela.config.json`'s `$schema` at it. ## Version requirements **No package declares an `engines` field** except the VS Code extension, which requires VS Code `^1.90.0`. **No package declares any `peerDependencies`** either. roblox-ts, TypeScript and React are ordinary dependencies throughout, so nothing is checked at install time. You find out from a build failure, and in the TypeScript case below, from one that does not mention versions. What the project is developed and tested against: | Tool | Version | |---|---| | Node | 24 (what CI runs) | | TypeScript | 5.9.3, pinned exactly — see below | | roblox-ts | `^3.0.0` | | `@rbxts/react` | `^17.3.7-ts.1` | | `@rbxts/services` | `^1.6.0` | > **TypeScript is an upper bound, not just a floor** > > `@vela-rbxts/rbxtsc-host` resolves `typescript` from **your** project and calls into its API directly. TypeScript 7 moved enough of that API to break the call. A project on the current TypeScript fails on its first file with `compiler-invocation-failed: Cannot read properties of undefined (reading 'Latest')`. > > `5.9.3` is the tested pin and `6.x` also builds. roblox-ts's own `typescript` does not save you — it pins `=5.5.3` for itself, while Vela reads the one in your project. `@rbxts/services` is a genuine runtime dependency: the runtime helper imports `UserInputService` and `Workspace` from it to evaluate variants. ### Prebuilt compiler binaries The compiler ships N-API binaries for these targets: - `x86_64-unknown-linux-gnu` - `x86_64-unknown-linux-musl` - `aarch64-unknown-linux-gnu` - `aarch64-apple-darwin` - `x86_64-apple-darwin` - `x86_64-pc-windows-msvc` There is **no `aarch64-pc-windows-msvc` build** and no linux arm64 musl build, so those two targets need the compiler crate built from source. Two things look like escape hatches and are not. The napi-rs loader ends with a WASI fallback reaching for `@vela-rbxts/compiler-wasm32-wasi`, which is not published. It always falls through to `Cannot find native binding.`, whose advice to reinstall without a lockfile is misleading on a platform that has no binary. And `@vela-rbxts/compiler-wasm` is a separate package with its own API, not a drop-in fallback. ## See also - [Configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md) for the schema `defineConfig` accepts. - [Diagnostics](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md) for what the transformer reports. --- # Release notes > What changed in each Vela release, and the sharp edges still open. Source: https://docs.astra-void.xyz/vela-rbxts/reference/release-notes/ Every package moves in lockstep, so a fix anywhere bumps everything. Releases are tagged from `v0.3.0`, and the upstream [`CHANGELOG.md`](https://github.com/astra-void/vela-rbxts/blob/main/CHANGELOG.md) carries the per-package detail. Installing for the first time? You are on the current release — skip to [what is still open](#still-open). ## 0.13.0 The current release: variants you define yourself, responsive ranges, configurable breakpoints, presets, per-corner radius, and transitions that reach the helper instances. **State variants.** `addVariant("open", { attribute: "State", equals: "open" })` registers an `open:` prefix that reads a Roblox attribute off the styled instance. `attr-[State=open]:` reads one inline. Both compose with every other variant. Only the condition travels to the runtime, so `open:rounded-lg` lowers `rounded-lg` exactly as a bare `rounded-lg` does. See [Responsive and input variants](https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants.md). **Responsive ranges.** `max-md:` is the exact complement of `md:`, minimum inclusive and maximum exclusive, so the two cover every viewport once. They also chain: `md:max-lg:` addresses one bucket. **Configurable breakpoints.** `theme.screens` is a theme axis like `colors`, so `tablet:` and `max-tablet:` are two lines of config. The default scale gains `xl` (1280) and `2xl` (1536). **Presets.** `presets: [gameUiPreset()]` folds a design system's theme, plugins, utilities and variants into a project in one line. They resolve after the defaults and before the config naming them, so a project always outranks what it pulled in. A JSON config can inline a preset but cannot import one. See [presets](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#presets). **Per-corner radius.** `rounded-l-lg`, `rounded-t-md` and `rounded-tr-[0.625rem]` write the individual `UICorner` properties. They **square off the corners they do not name**, so `rounded-r-lg` alone keeps the left side flat. A directional utility beats the all-corner shorthand on the corners it names, in either order. **Transitions reach the helper instances.** `hover:rounded-xl` and `hover:border-blue-500` used to snap, since a radius and a stroke live on `UICorner` and `UIStroke` rather than on the element. They tween now, and `transition-shadow` joins the property groups. A custom motion driver receives those tweens with a fourth argument naming the helper. It is additive, so a three-argument driver keeps working. **Optional inlay hints.** The editor can show what each class lowers to after the class. Off by default, behind `velaRbxts.inlayHints.enabled`. **A `vela.config.ts` is executed once per build**, resolved per directory and re-read only when it changes. A config that throws is cached alongside one that resolves, so a typo is reported once and the next edit lifts it. **Three new prefix diagnostics.** `unknown-breakpoint` fires on a `max-` in front of something that is not a breakpoint. `malformed-attribute-variant` fires on an `attr-[…]` that does not parse. `invalid-breakpoint-range` fires on a chain whose bounds leave no viewport. Sorting ranks the new variants in bands of their own. The existing variants keep the order they had. ## 0.12.8 **The published editor extension carried no config loader**, so a project's `vela.config.ts` was never read and every key it defined was checked against the default theme. Upgrade if the editor reports your own theme keys as unknown. ## 0.12.7 **`justify-stretch`**, which sets `UIListLayout.HorizontalFlex` to `Enum.UIFlexAlignment.Fill`. `items-stretch` reached `VerticalFlex` from the start, but nothing reached the horizontal axis. A column that wanted its children to fill the width had no class for it. **A config the editor cannot read is now a notification** naming the file and the reason, rather than a line in an output channel nobody opens. ## 0.12.6 Three fixes, all a walk that stopped at a boundary too early. The `SurfaceGui` pin and an `opacity-*` fade now carry through a **fragment, a wrapper or a provider** rather than turning around at one. A component exported without a name, as `export default (props) => …`, is read as a component root like the named form beside it. ## 0.12.5 **A `SurfaceGui` keeps its literal pixels.** It takes its pixel space from the part it is drawn on rather than from the viewport. A `BillboardGui` sizes itself the same way, so following the rem curve there was wrong. Both are pinned now, on the static and runtime paths alike. `theme.rem.pinnedUnder` names them, and emptying it puts them back on the curve. A container the compiler never sees is outside what this reaches. Pin such a project with `theme.rem: { min: 16, max: 16 }`. ## 0.12.4 **A margin side is one signed slot, so the last class written to it lands.** Padding and a negative shift used to accumulate separately. Neither could overwrite the other. `-ml-2 -ml-2` shifted by 16 rather than 8, and `ml-4 -ml-2` applied both a padding *and* a shift. A side that ends up negative moves the element. One that ends up positive pads it. `-mr-*` and `-mb-*` still report `unsupported-negative-margin`. **Sorting leaves the whitespace where its author put it**, so a class list written across several lines is no longer flattened on every save. Two smaller fixes. `placeholder-transparent` is no longer offered by completion, since Roblox has no placeholder transparency to lower to. The language server exits on the `exit` notification instead of waiting for the pipe to close. ## 0.12.3 A patch, mostly in the editor — but two of these move the compiler as well. **A class an interpolation splices into is left alone.** `` `w-[${width}]` `` reaches the editor as two pieces. Both were analyzed as whole classes, warning about a class you did not write. **Whitespace inside an arbitrary value no longer splits it.** `w-[calc(100% - 4px)]` was read as three classes and collected a diagnostic each. It reports the one it is owed now. The runtime splits its class strings under the same rule, so a static class and a deferred one tokenize alike. Four editor fixes behind them. A config pushed by the editor never arrived, so your theme keys stayed unknown for the session. A file opening with a BOM answered one character to the left. Completing inside a variant chain deleted the utility behind it. Sorting a value whose bracket never closes rewrote it. ## 0.12.2 A patch, entirely in the editor — the compiler emits byte for byte what 0.12.0 did. **The editor understands a class value written as a function.** `className={() => "bg-blue-600"}` is how a Vide project writes a dynamic one. The walk had no arm for a function at all, so completions, hover, diagnostics, swatches and sorting saw nothing in it. It follows what a function returns now, along with template interpolations, `as const`, `satisfies`, string concatenation, and an object's computed keys and spreads. Two interpolation bugs went with it. A half-typed file no longer reports `${flag` as an unknown utility, and the sort keeps the whitespace either side of an interpolation. ## 0.12.1 The one to be on if you write Vide. **A Vide project that never named `framework` compiled against the React host**, emitting an import of `@rbxts/vela-runtime` it has no reason to have installed. The tsconfig inference only ran for a project with no `vela.config.ts` at all. A Vide project whose config set a theme or a plugin silently lost it. ## 0.12.0 The largest release since 0.7.0. Offsets became rem units. The runtime became a package instead of a copy. Branches started resolving at compile time, and Vela learned to emit for a second UI library. > **Two of these change what you already have** > > **Rendering moves on any viewport but the base one**, because every pixel offset now scales. And > **`className={a ? "x" : "y"}` now reports diagnostics it used to swallow**, so an upgrade can surface > warnings on code that was already wrong. Neither needs a code change. Nothing about install changes. **Every pixel offset a utility lowers is a rem unit.** `p-4`, `w-40`, `rounded-lg` and `text-sm` are measured against the viewport. One rem is 16px at 1920×1020 and scales from there, with no provider, hook or wrapper involved. Scale-valued utilities are untouched: `w-full` and `translate-x-1/2` stay fractions of the parent. `TextSize` gains a ceiling of 100, where Roblox stops honouring it. To keep literal pixels, close the clamp with `theme: { rem: { min: 16, max: 16 } }`. The compiler then drops the scaling from the emit entirely. See [rem](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem). **The runtime ships as `@rbxts/vela-runtime` instead of being copied into every file.** Ten components used to carry ten copies of the same 5,500 lines. Each copy had its own camera subscription. It is now one ModuleScript the whole place shares. In the reference app `App.luau` went from 190,260 bytes to 27,912. Setup is unchanged — the package installs with `vela-rbxts` and sits under the `@rbxts` scope every roblox-ts project already lists. **The emit sends the theme you changed, not the whole palette.** A module whose host never has to *parse* a class value sends its scales emptied entirely. **A branch's classes are resolved at compile time.** `active ? "text-lg" : "text-sm"` names every token it can apply. The compiler resolves all of them and hands the element the resolved props alongside the tests that decide them. The full utility set applies inside a branch. A bad utility in one reports a diagnostic instead of vanishing, and each test is evaluated once however many branches hang on it. It reads ternaries, `&&`, the literal behind `||`, arrays and object maps. A branch naming `m-*`, `divide-*`, `animate-*`, `transition*`, a text transform or `opacity-*` still takes the whole value down the runtime path. See [dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md#a-branch-lowers-at-compile-time). **`/N` opacity modifiers lower on every family with a transparency channel.** `border-slate-500/25`, `divide-white/10` and the gradient stops previously lost exactly the alpha they were written for. `placeholder-*` is the one family left, and its message now says why. **Vela emits for [Vide](https://centau.github.io/vide/).** One config key, `framework: "vide"`, or nothing at all — it is inferred from a `jsxFactory` beginning with `Vide.`. A statically lowered element is identical under both targets. What differs is the runtime package the emit imports, and that a dynamic class value is written as a thunk. Two limits are inherent. A component element's prop names are fixed when it is called, and a `m-*` arriving out of an opaque call is warned rather than rendered. See [the Vide guide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md). Smaller things. Hovers and completions read offsets in rem, and arbitrary values read a `rem` unit. A base helper a variant rule overwrote no longer leaves two `UIPadding` under one instance. The rule prop parser learned `Vector2`, `ColorSequence`, `NumberSequence` and `Font`. ## 0.11.1 A patch. The runtime failed to typecheck in a project that sets `noUncheckedIndexedAccess`. That matters because a `className` carrying a state variant pulls it into the emit, where **your** compiler options check it. ## 0.11.0 The first release that does not change how a single class lowers. It makes the transformer optional. **There is a `vela` command now**, for any project that cannot register a roblox-ts transform plugin. A pinned toolchain, a build system that drives `tsc` itself, or a CI step that wants the lowered sources as a reviewable artifact. `vela build` mirrors `src` into `.vela/src`, transforming the files that use `className` and copying everything else byte for byte. `vela watch` re-transforms on change. Both paths call the same compiler, so the output does not depend on which you pick. An identical output is never rewritten, and pruning reads a manifest of what an earlier run emitted, so a file the CLI never wrote is never deleted. See [the CLI reference](https://docs.astra-void.xyz/vela-rbxts/reference/cli.md). **A whole-tree build stops re-evaluating `vela.config.ts` once per file.** Upgrading from 0.10.0 cannot change your output. ## 0.10.0 Finishes the `opacity-*` work 0.9.0 started, and stops the inlined runtime from crowding Luau's local register limit. **`opacity-*` crosses a component boundary, in both directions.** 0.9.0 faded a subtree by walking the JSX, and that walk ends where the JSX does. A fade written *around* a component reached nothing it rendered, and one written *on* a component stopped at `BackgroundTransparency`. The alpha now travels as React context, through a provider that renders no instance, so the tree keeps its shape and names. Providers are *relative*: two nested fades compose rather than the inner one winning. **A class value that settles at render time is left whole to the runtime.** An `opacity-*` written inside a recipe reaches the subtree it is written over. **`opacity-unreachable-child` is gone.** One difference between the paths remains. The static path leaves a transparency *you* declared as a prop alone, while a fade arriving as context composes over both. **A configured motion driver no longer takes the tree down at mount.** A driver written the documented way compiles to a method carrying an implicit `self`. The runtime called it detached, shifting every argument one place left. Methods are called as methods now, and the driver type states them as methods. The arrow form is a compile error rather than a tree that disappears. See [the motion driver](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#motion-driver). **The inlined runtime is grouped into namespaces**, taking the busiest register file in an emitted file from 177 to 65 against Luau's cap of 200. Both the harness and the compiler crate assert a budget of 120, so crowding the limit again fails a test rather than your build. ## 0.9.0 **`opacity-*` fades everything the element draws** — every channel the host paints, not just `BackgroundTransparency`, which was invisible on a label whose background was already transparent. **And into the subtree written under it.** Roblox has no inherited transparency, so the transformer walks the JSX. Every instance below the class is handed the running product `1 - (1 - own) * alpha`. Children written inside an expression count. A `canvasgroup` on the way down ends the walk. Where this parts ways with a real composite is overlapping siblings: each is faded rather than the group, so the overlap darkens. **`opacity-*` stopped being order-dependent.** It is held until the whole class list is read, then composed over whatever alpha the colours settled on. `opacity-50 bg-slate-700` and `bg-slate-700 opacity-50` are the same thing. **The inlined runtime stopped costing every file 96 registers.** It had been failing whole components with `Out of local registers … exceeded limit 200`. That was against code nobody wrote. If you are on 0.9.x and see `opacity-unreachable-child`, it is telling the truth — `{props.children}` and component children stayed out of the compile-time walk. 0.10.0 carries the alpha across both. ## 0.8.0 **Every utility family resolves on the runtime class path.** The runtime host had implemented roughly a third of what the static path lowers. A component whose `className` arrived as a *value*, the normal shape for a variant recipe, silently lost most of the rest. Positioning, box constraints, the grid, gradients, rings, shadows, `z-*`, transforms, `opacity-*`, the ScrollingFrame family and most of the text families. All of them resolve dynamically now, with the static path's semantics. This is the release where [dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) stopped being a reduced dialect. **A utility the host element cannot carry is dropped at runtime rather than applied**, since writing `TextColor3` onto a `Frame` is a hard Roblox error. The static path still writes it — see [still open](#still-open). **The runtime host names `UIShadow` by its real class.** The lowercase spelling failed to instantiate and unwound the whole tree. Fixed on the runtime path only. ## 0.7.0 The release that drew most of the current configuration surface. - **Plugins.** `addUtilities` registers class names of your own, as a list of existing utilities or as Roblox properties written directly, and `theme()` reads the resolved theme. Registered utilities take variants, resolve on both paths, and sort *ahead* of the plain utilities. See [`plugins`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#plugins). - **A motion driver seam.** `setMotionDriver` names a module whose `transition`/`animate` methods replace TweenService, one method at a time. - **`dark:`, `active:` and `focus:` variants.** `dark:` reads a `VelaColorScheme` attribute off the local player, since Roblox exposes no color scheme to a running game. - **`theme.fontFamily` and `font-{family}`**, shipping `sans`, `serif` and `mono` and taking any Roblox font family asset. - **Scrolling frame utilities**, arbitrary length values (`[16px]`, `[50%]` and their negatives), and real property filters on `transition-colors`/`-opacity`/`-transform`. - **Class sorting**, exposed as the `source.sortVelaClasses` source action. See [editor setup](https://docs.astra-void.xyz/vela-rbxts/guides/editor-setup.md#sorting-class-names). ## 0.6.0 > **Breaking-ish: grid cells are sized now** > > `UIGridLayout` stamps `CellSize` onto every child, and Vela's grid utilities never set it. Every cell fell back to Roblox's 100×100 default, so a `grid-cols-2` of 430px cards collapsed to 100px squares. `grid-cols-N` now divides the axis into N tracks and hands each cell its share of the gap. > > The cross axis needs its own answer, so **`auto-rows-*` and `auto-cols-*`** set it from the spacing > scale. Without one it stays at 100px, so existing grids keep their row extent and gain correct track > widths — anything compensating for 100px cells needs re-checking. **`text-{color}` resolves on the runtime path.** The resolver had no `text-` branch at all. Every text colour in a dynamic class value was dropped without a diagnostic. **`w-*` and `h-*` stop erasing each other in variant rules**, carrying the 0.5.2 fix into `md:w-32 md:h-32`. ## 0.5.2 **`w-*` and `h-*` stop erasing each other on the runtime path.** `Size` holds both axes. A runtime rule naming one used to state a whole `UDim2` and zero the other. Worth re-reading if you worked around this: the axis that lost was left at zero, which also made a scale-based `rounded-full` compute to nothing. ## 0.5.1 A version-only release — no compiler change. ## 0.5.0 > **Breaking: preflight neutralizes the Roblox host defaults** > > Roblox paints every `GuiObject` as an opaque gray box with a 1px border, and a framework that only > ever *adds* properties can never take that back. > > A supported host element **that carries a `className`** now starts from `BackgroundTransparency = 1` > and `BorderSizePixel = 0`. Anything that actually paints opts back out: a `bg-*` utility, > `opacity-*`, a gradient stop, or a transparency you declared as a prop. A background painted by a > variant reopens it at runtime. An element with no `className` is untouched, and so is a > **component**. > > **Anywhere the gray default was load-bearing, the element now renders invisible.** Add the `bg-*` it > relied on, or set [`preflight: false`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight). **`order-*` works under `flex`.** Vela's `UIListLayout` did not set `SortOrder`, and the engine default is `Name`, so children sorted alphabetically and every `order-*` was a silent no-op. If you still see `order-*` ignored, the layout instance is one you wrote yourself. ## 0.4.2 **A WebAssembly build of the compiler**, `@vela-rbxts/compiler-wasm`, so lowering can run in a browser. The [playground](https://docs.astra-void.xyz/vela-playground/) compiles with it. **Transitions tween from the right starting value**, instead of jumping to the target the first time a variant introduced the prop. **A base opacity modifier no longer leaks into a variant**, and refs on runtime-path elements are typed from the tag rather than as `Ref<unknown>`. ## 0.4.1 > **Breakpoints did not work in 0.4.0** > > The `0.4.0` runtime helper read `Camera.ViewportSize` **once, at mount**, and Roblox often still reports a 1×1 viewport then. **`sm:`, `md:` and `lg:` never matched, and orientation was always landscape.** `0.4.1` follows the camera's signal, which fixes the initial evaluation and makes > breakpoints react to resizes. The input variants and `hover:` were never affected. **`divide-*` drew one separator too many** whenever the element carried a utility that lowers a helper instance. Config errors name the key that failed, and `tsx-parse-failed` carries a real source range. Malformed `configJson` is an error rather than a silent fallback to the default theme. ## 0.4.0 Where `hover:`, arbitrary hex colors and `/N` opacity modifiers arrived. Breakpoints and orientation are broken in this release, as described above. If you are pinned to it, drive reflow from your own state. See [Responsive and input variants](https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants.md#driving-reflow-yourself). ## Earlier **0.3.0** landed most of the surface this documentation describes at once. Far-edge positioning, `content-*`/`self-*`, `order-*`, grid, `translate-*`, `space-x/y-*`, `ring`/`outline`, `leading-*`, the case transforms and decorations, the whole motion layer, and the structural utilities. **0.2.1** added `vela.config.json` and the palette `DEFAULT` key, and moved the declaration file to `src/vela-env.d.ts`. `src/vela-rbxts.d.ts` collides with the package name under a `baseUrl` of `src`, so the augmentation silently never loaded. **0.2.0** was the first public release. ## Still open Known and unfixed as of 0.13.0. **`gap-*` on a `grid` element emits a conflicting `UIListLayout`** alongside the `UIGridLayout` it correctly feeds. Set `CellPadding` as a prop and leave `gap-*` off ([utilities reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#grid)). **`divide-*` separators are sensitive to explicit `LayoutOrder`** and double-count the parent's `gap-*` — a consequence of separators being list items ([utilities reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#divide)). **The static path emits `<uishadow>` in lowercase**, which `Instance.new` rejects, so a statically lowered `shadow-*` can fail to instantiate and unwind the tree around it. A dynamic class value on the same element is unaffected. **The host guards are not symmetric.** The runtime path drops a utility the host element cannot carry. The static path warns `unsupported-host-utility` and writes it anyway. A build that ignores warnings reaches Roblox, which rejects the assignment far from the class list that caused it. **A fade that arrives as context composes over a transparency you declared yourself.** A consumer reading the alpha at runtime cannot tell your prop from one Vela lowered. Overlapping siblings under one `opacity-*` also darken where they overlap. **A plugin utility that reaches back into itself expands to nothing** — the depth cap terminates silently, with no diagnostic. **`divide-transparent` is refused while `divide-white/10` resolves.** The `/N` modifier moved to every family with a transparency channel in 0.12.0. The `transparent` *keyword* did not. **Under Vide, a `m-*` the compiler cannot see is warned rather than rendered**, because a margin box is an instance above one Vide has already parented. **A theme value that is not a valid expression fails silently, three ways.** Vela splices in the first expression that parses and drops the rest. A trailing comment is lost, `"#3b82f6"` becomes a type error, and `"#a1b2c3"` parses as a TypeScript private name and emits bare. See the [theming guide](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#values-are-source-code-not-values). **TypeScript 7 is not supported.** 7.x fails the host adapter's call with `compiler-invocation-failed: Cannot read properties of undefined (reading 'Latest')` on the first file. Pin `typescript@5.9.3` or `6.x`. Nothing declares this as a peer range, so the install is clean and the build is not. ## Platform coverage Prebuilt binaries do not cover every target: there is no `aarch64-pc-windows-msvc` build, so **Windows on ARM is unsupported**, and no linux arm64 musl build either. On those two, build the compiler crate and the language server from source. Full target list in the [API reference](https://docs.astra-void.xyz/vela-rbxts/reference/api.md#prebuilt-compiler-binaries). ## See also - [Scope and status](https://docs.astra-void.xyz/vela-rbxts/getting-started/scope-and-status.md) — what is in and out at 0.13.0. - [Configuration](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight) — the `preflight` opt-out, and [`plugins`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#plugins). - [Troubleshooting](https://docs.astra-void.xyz/vela-rbxts/guides/troubleshooting.md) — symptom-first.