# 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.
