# 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"
<frame className="bg-slate-800 rounded-lg border border-slate-700 shadow-lg">
  <textlabel className="text-slate-100" Text="Ready" />
</frame>
```

_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"
<frame className="border-2 border-slate-600 border-miter" />
```

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
<frame className="rounded-card" />
```

## 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
<frame className="shadow-lg shadow-slate-950" />
```

## 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"
<frame className="bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500" />
```

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