Velaguides

Layout and sizing

How the layout, sizing, position and transform utilities lower to Roblox layout instances.

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:

ClassProperty
flexFillDirection = Enum.FillDirection.Horizontal
flex-rowFillDirection = Enum.FillDirection.Horizontal
flex-colFillDirection = Enum.FillDirection.Vertical

flex-row-reverse and flex-col-reverse produce unsupported-flex-directionUIListLayout 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.

ClassPropertyValue
justify-startHorizontalAlignmentEnum.HorizontalAlignment.Left
justify-centerHorizontalAlignmentEnum.HorizontalAlignment.Center
justify-endHorizontalAlignmentEnum.HorizontalAlignment.Right
justify-betweenHorizontalFlexEnum.UIFlexAlignment.SpaceBetween
justify-aroundHorizontalFlexEnum.UIFlexAlignment.SpaceAround
justify-evenlyHorizontalFlexEnum.UIFlexAlignment.SpaceEvenly

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.

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

ClassUIFlexItem property
flex-1FlexMode = Enum.UIFlexMode.Fill
flex-autoFlexMode = Enum.UIFlexMode.Fill
flex-initialFlexMode = Enum.UIFlexMode.Shrink
flex-noneFlexMode = Enum.UIFlexMode.None
growFlexMode = Enum.UIFlexMode.Grow
grow-0FlexMode = Enum.UIFlexMode.None
shrinkFlexMode = Enum.UIFlexMode.Shrink
shrink-0FlexMode = Enum.UIFlexMode.None
self-auto, self-start, self-center, self-end, self-stretchItemLineAlignment

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 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:

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.

Padding and UIPadding

Padding utilities collect into one UIPadding child. The axis mapping is direct:

ClassProperties 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, even in a plain string literal. The variants:

ClassEffect
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-autostatic 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.

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:

DenominatorAccepted numerators
/21
/31, 2
/41, 3
/51, 2, 3, 4
/61, 5
/121 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:

ClassProperty
w-fit, w-autoAutomaticSize = Enum.AutomaticSize.X
h-fit, h-autoAutomaticSize = Enum.AutomaticSize.Y
size-fit, size-autoAutomaticSize = Enum.AutomaticSize.XY
w-fit h-fitAutomaticSize = 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)
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.

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:

ClassEffect
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-*:

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:

ClassAnchorPoint
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:

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.

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:

InputDiagnostic
z-autounsupported-z-index-auto
-z-10unsupported-negative-z-index
z-[7]unsupported-arbitrary-z-index
z-5unsupported-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:

ClassScale
scale-00
scale-500.5
scale-750.75
scale-900.9
scale-950.95
scale-1001
scale-1051.05
scale-1101.1
scale-1251.25
scale-1501.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