# 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"
<frame className="flex justify-between items-center gap-2">
  <textlabel className="text-lg" Text="Inventory" />
  <textbutton className="px-3 py-2 rounded-md bg-slate-700" Text="Close" />
</frame>
```

_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"
<uigridlayout FillDirectionMaxCells={2} CellSize={new UDim2(0.5, -5, 0, 100)} CellPadding={UDim2.fromOffset(10, 10)} />
```

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"
<frame className="w-full h-12" />
// Size = UDim2.new(1, 0, 0, 48)

<frame className="w-1/2 h-1/2" />
// Size = UDim2.fromScale(0.5, 0.5)

<frame className="w-40 h-8" />
// 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"
<frame className="right-2 bottom-2 origin-bottom-right w-24 h-8" />
```

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"
<frame className="left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-40" />
```

`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"
<imagelabel className="w-full aspect-[16/9]" />
<frame className="w-40 aspect-[2.35]" />
```

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