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 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, 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 |
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 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.
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.
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 |
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 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-noneoutline-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.
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:
<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, plain string literal or not.
| Class | Values | Effect | Notes |
|---|---|---|---|
m-{key} | spacing value | margin box, all four sides | runtime |
mx-{key}my-{key} | spacing value | margin box, one axis | runtime |
mt-{key}mr-{key}mb-{key}ml-{key} | spacing value | margin box, one side | runtime |
-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 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-xdivide-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-autow-fit | — | AutomaticSize = Enum.AutomaticSize.X | |
h-autoh-fit | — | AutomaticSize = Enum.AutomaticSize.Y | |
size-autosize-fit | — | AutomaticSize = Enum.AutomaticSize.XY | Also when both axes are set |
Accepted values on the sizing axes:
px— one pixel of offset.full— scale1.- 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, and1/12through11/12. Unreduced forms such as2/4,3/6, and2/6are 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-startjustify-centerjustify-end | HorizontalAlignment = Left, Center, Right | |
justify-betweenjustify-aroundjustify-evenly | HorizontalFlex = Enum.UIFlexAlignment.SpaceBetween, SpaceAround, SpaceEvenly | Different property |
items-startitems-centeritems-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:
<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, so auto-rows-24 is 96px.
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.
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:
<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 |
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 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.
<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 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-* |
italicnot-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-lefttext-centertext-right | — | TextXAlignment | |
text-justify | — | — | unsupported-text-alignment |
align-topalign-middlealign-bottom | — | TextYAlignment = Top, Center, Bottom | |
text-wraptext-nowrap | — | TextWrapped = true, false | |
whitespace-normalwhitespace-nowrap | — | TextWrapped = true, false | Alias family; others are unsupported-whitespace-value |
truncate | — | TextTruncate = Enum.TextTruncate.AtEnd | |
uppercaselowercasecapitalizenormal-case | — | the Text string itself | Rewritten at compile time when Text is a literal |
underlineline-throughno-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, so font-bold font-mono italic
lowers to one FontFace:
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
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 |
<scrollingframe className="scroll-y canvas-auto-y scrollbar-w-2 scrollbar-zinc-600/50" /><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.
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.
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.
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 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; 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.
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.
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 for the cost model and for what promotes an element onto this path.
See also
- Configuration for the theme keys these utilities look up, and
pluginsfor registering utilities of your own. - Diagnostics for every warning code named on this page.
- Recipes for these utilities composed into real interface pieces.