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