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 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.
<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>import React from "@rbxts/react";
export function Toolbar() { return ( <frame className="flex flex-col gap-3 w-96 h-36 p-4 rounded-lg bg-slate-900 border border-slate-800"> <frame className="flex items-center gap-3 w-full h-10 px-3 rounded-md bg-slate-800"> <textlabel className="w-52 h-5 text-left text-slate-100 text-sm" Text="Inventory" /> <textbutton className="w-16 h-7 rounded-md bg-sky-500 text-white text-sm" Text="Close" /> </frame>
<frame className="flex justify-center items-center gap-3 w-full h-14 p-2 rounded-md bg-slate-800"> <frame className="w-14 h-10 rounded bg-slate-700 border border-slate-600" /> <frame className="w-14 h-10 rounded bg-slate-700 border border-slate-600" /> <frame className="w-14 h-10 rounded bg-slate-700 border border-slate-600" /> </frame> </frame> );}import { __VelaBoundary } from "@rbxts/vela-runtime";import React from "@rbxts/react";export function Toolbar() { return <__VelaBoundary.Consume>{(<frame BackgroundColor3={Color3.fromRGB(15, 23, 43)} Size={UDim2.fromOffset(384, 144)} BorderSizePixel={0}><uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, 12)} SortOrder={Enum.SortOrder.LayoutOrder}/><uipadding PaddingTop={new UDim(0, 16)} PaddingRight={new UDim(0, 16)} PaddingBottom={new UDim(0, 16)} PaddingLeft={new UDim(0, 16)}/><uicorner CornerRadius={new UDim(0, 8)}/><uistroke Thickness={1} Color={Color3.fromRGB(29, 41, 61)} Transparency={0}/> <frame BackgroundColor3={Color3.fromRGB(29, 41, 61)} Size={new UDim2(1, 0, 0, 40)} BorderSizePixel={0}><uilistlayout FillDirection={Enum.FillDirection.Horizontal} VerticalAlignment={Enum.VerticalAlignment.Center} Padding={new UDim(0, 12)} SortOrder={Enum.SortOrder.LayoutOrder}/><uipadding PaddingLeft={new UDim(0, 12)} PaddingRight={new UDim(0, 12)}/><uicorner CornerRadius={new UDim(0, 6)}/> <textlabel Text="Inventory" TextXAlignment={Enum.TextXAlignment.Left} TextColor3={Color3.fromRGB(241, 245, 249)} TextSize={14} Size={UDim2.fromOffset(208, 20)} BorderSizePixel={0} BackgroundTransparency={1}/> <textbutton Text="Close" BackgroundColor3={Color3.fromRGB(0, 166, 244)} TextColor3={Color3.fromRGB(255, 255, 255)} TextSize={14} Size={UDim2.fromOffset(64, 28)} BorderSizePixel={0}><uicorner CornerRadius={new UDim(0, 6)}/></textbutton> </frame>
<frame BackgroundColor3={Color3.fromRGB(29, 41, 61)} Size={new UDim2(1, 0, 0, 56)} BorderSizePixel={0}><uilistlayout FillDirection={Enum.FillDirection.Horizontal} HorizontalAlignment={Enum.HorizontalAlignment.Center} VerticalAlignment={Enum.VerticalAlignment.Center} Padding={new UDim(0, 12)} SortOrder={Enum.SortOrder.LayoutOrder}/><uipadding PaddingTop={new UDim(0, 8)} PaddingRight={new UDim(0, 8)} PaddingBottom={new UDim(0, 8)} PaddingLeft={new UDim(0, 8)}/><uicorner CornerRadius={new UDim(0, 6)}/> <frame BackgroundColor3={Color3.fromRGB(49, 65, 88)} Size={UDim2.fromOffset(56, 40)} BorderSizePixel={0}><uicorner CornerRadius={new UDim(0, 4)}/><uistroke Thickness={1} Color={Color3.fromRGB(69, 85, 108)} Transparency={0}/></frame> <frame BackgroundColor3={Color3.fromRGB(49, 65, 88)} Size={UDim2.fromOffset(56, 40)} BorderSizePixel={0}><uicorner CornerRadius={new UDim(0, 4)}/><uistroke Thickness={1} Color={Color3.fromRGB(69, 85, 108)} Transparency={0}/></frame> <frame BackgroundColor3={Color3.fromRGB(49, 65, 88)} Size={UDim2.fromOffset(56, 40)} BorderSizePixel={0}><uicorner CornerRadius={new UDim(0, 4)}/><uistroke Thickness={1} Color={Color3.fromRGB(69, 85, 108)} Transparency={0}/></frame> </frame> </frame>)}</__VelaBoundary.Consume>;}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 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:
<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.
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, 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.
- Theme lookup. The key is looked up in
theme.spacing. If it is there, that roblox-ts expression string is used verbatim. - 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 isnew 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.
theme.spacing ships with exactly "4". Every other numeric key works through the arithmetic fallback. Adding named keys is covered in Theming.
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. 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)
<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:
| 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-*:
<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:
<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.
<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-*.UIGridLayouthas no span concept, so these reportno-roblox-equivalent. - The CSS positioning model:
absolute,relative,fixed,sticky,float,clear. Roblox positioning is always parent-relative. - Axis gaps:
gap-x-*andgap-y-*. They match thegap-prefix, so the leftover text is read as a spacing key and the failure comes out asunknown-theme-keyrather than an unknown-family warning. - Reversed fill:
flex-row-reverse,flex-col-reverse. Useorder-*.
See also
- Colors and surfaces for
bg-*,border-*, gradients, and shadows. - Responsive and input variants for breakpoint- and input-conditional layout.
- Recipes for these families composed into real interface pieces.
- Utilities reference for the complete class list.
- Diagnostics for every warning code named on this page.