Velaguides

Recipes

Small, complete UI patterns — buttons, badges, meters, list rows, a dialog — compiled and rendered live.

The other guides explain Vela one utility family at a time. This page builds the small pieces most Roblox interfaces are made of. Every preview is compiled by the same compiler rbxtsc loads: Classes for the source, Lowered for what comes out. Every example stays on the static path.

Two conventions repeat throughout, both worth internalizing before you copy anything:

  • Everything has an explicit size. Roblox instances default to zero-height Size values, which is why the examples write w-* and h-* on almost everything. The noise is Roblox’s defaults, not Vela’s requirements. Backgrounds are not in that bucket any more: since 0.5.0 preflight starts every classed element transparent, so a label that should not paint needs no bg-transparent.
  • Rows and columns are UIListLayout. Any element with flex, gap-*, justify-* or items-* on it gets exactly one UIListLayout child. That layout owns the positions of every child under it. See layout and sizing for the full model.

Buttons

Filled, outlined, and destructive — the outline is a UIStroke, and everything else is four utility families.

A button is a textbutton with sizing, a radius, a background and text styling. The difference between the filled and outlined variants is one border border-slate-600 pair, which lowers to a UIStroke child.

Hover feedback comes straight from the hover: variant, and pairing it with transition tweens the change instead of snapping it:

Hovering tweens the fill over 150ms
<textbutton
className="w-28 h-10 rounded-md bg-sky-500 hover:bg-sky-600 transition duration-150 text-white text-sm font-semibold"
Text="Play"
/>

A variant moves the element onto the runtime path, and a gamepad or touch player may never hover — treat it as feedback, not the only signal.

There is no active: or pressed: variant. Press feedback is state you own. Track it with React events and branch between two fully static class strings. Both looks stay on the static path with the full utility set:

src/client/ActionButton.tsx
export function ActionButton(props: { label: string; onClick: () => void }) {
const [pressed, setPressed] = React.useState(false);
return pressed ? (
<textbutton
className="w-28 h-10 rounded-md bg-sky-600 text-white text-sm font-semibold"
Text={props.label}
Event={{
Activated: props.onClick,
MouseButton1Up: () => setPressed(false),
MouseButton1Down: () => setPressed(true),
}}
/>
) : (
<textbutton
className="w-28 h-10 rounded-md bg-sky-500 text-white text-sm font-semibold"
Text={props.label}
Event={{
Activated: props.onClick,
MouseButton1Up: () => setPressed(false),
MouseButton1Down: () => setPressed(true),
}}
/>
);
}

The duplication is the point: one computed string would push the element onto the runtime path, which resolves the same utilities but checks none of them.

Buttons do not size to their label. A bare textbutton is zero-sized. w-fit/h-fit lower to AutomaticSize, but a fixed w-28 h-10 is the predictable default for anything sitting in a row with other buttons.

Badges

Status pills: rounded-full, a dark shade for the fill, a light shade of the same palette for the text.

A pill is a textlabel with rounded-fullnew UDim(0.5, 0), half the instance’s own height, so it stays a capsule at any size. Roblox centres label text by default, so no alignment classes are needed, and the label’s own background paints the pill.

The colour pattern is worth stealing: fill from the dark end of a palette, border one step lighter, text from the light end. Every built-in palette carries the same eleven shades, so it transfers to any hue — swap emerald for amber, rose, or a palette of your own.

Stat bars

A meter is a track and a fill: the fraction is the value, and the fill sits at the track's origin because a plain frame does not lay out its children.

A meter needs no layout instance. The track is a rounded-full frame, and the fill a plain child with w-2/3 h-full. With no UIListLayout on the track, the fill sits at its top-left origin. Fractions lower to the scale component of Size, so w-2/3 means two thirds of the track with no pixel maths.

To drive the fill from live data, resist the computed class string. w-* is in the runtime resolver’s subset, but you would pay for the whole runtime path to express a number. Set Size directly and let the classes carry what does not change:

The fraction is data — pass it as a prop
<frame className="w-72 h-2 rounded-full bg-slate-800">
<frame className="rounded-full bg-emerald-500" Size={UDim2.fromScale(health, 1)} />
</frame>

One rule makes this safe. Never set the same property from a class and a prop on one element. On a collision Vela emits after you, and the class wins. An h-full beside that Size prop would overwrite it with a Size = (0, 1) scale.

List rows

A column of rows, each row its own UIListLayout. Column widths are chosen so rank, avatar, name, and score fill the row exactly.

Lists are the pattern Roblox UIs live in: a flex flex-col gap-2 column whose children are flex items-center gap-3 rows. Each element gets its own UIListLayout, so nesting costs nothing to reason about.

The score column is pushed to the right edge with fixed widths. Rank, avatar, name and score plus the gaps add up to the row’s inner width. justify-between exists and lowers to UIListLayout.HorizontalFlex, but the renderer behind these previews does not implement flex distribution. Both are legitimate in a real place, and the fixed-width one is what you need whenever a column must not shrink.

In real code the rows come from data, and a className written as a literal inside the callback is still a static string:

Still the static path
{entries.map((entry, index) => (
<frame className="flex items-center gap-3 w-full h-9 px-3 rounded-md bg-slate-800" key={entry.id}>
{/* … */}
</frame>
))}

What matters is the expression in the attribute, not where it appears. className={rowClasses} with a computed rowClasses is dynamic. The literal above is not.

A confirmation dialog

Column layout, wrapped body text, and a justify-end button row — the alignment half of justify-*, which is plain UIListLayout.

The dialog combines everything above. A column with gap-3, a text-wrap body, and a justify-end row for the buttons that lowers to HorizontalAlignment = Right. TextWrapped is off by default, so long text clips without it.

To present it as a modal, parent the card to a full-screen scrim and lift it above the rest of the interface:

src/client/Modal.tsx
<frame className="size-full bg-slate-950 opacity-60 z-50">
<frame className="origin-center left-1/2 top-1/2 w-96 h-40 …">{/* the card */}</frame>
</frame>

Three details do the work. opacity-60 maps to BackgroundTransparency, inverted. origin-center sets AnchorPoint to (0.5, 0.5), so left-1/2 top-1/2 centres the card rather than placing its corner. And z-50 raises ZIndex. The scrim’s transparency does not cascade, so the card keeps its own opaque background.

When a recipe needs state

Every pattern here eventually meets data: a selected tab, a disabled button, a filling meter. The rule is always the same:

  • The look changes between known states → branch between two complete static literals, as the button does. Full utility set, full diagnostics, zero runtime cost.
  • A number changes continuously → keep the classes static and set the property directly, as the meter does, on a property no class on that element touches.
  • Only a colour, radius, spacing or size varies → a computed string is acceptable, since those families are within the runtime resolver’s subset. Know that you are opting into the runtime path for that element.
  • The state is the pointer being over the element → that one is built in: hover:, ideally with transition.

What you should not do is compute a string carrying layout classes — flex, items-center, alignment, text styling. Those are dropped silently at runtime, and the failure mode is a broken screen with a clean build. See dynamic class names, or the short version in the five rules.

See also