# Recipes

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

Source: https://docs.astra-void.xyz/vela-rbxts/guides/recipes/

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](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#the-two-lowering-paths).

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](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#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](https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing.md) for the full model.

## Buttons

_Interactive preview: 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](https://docs.astra-void.xyz/vela-rbxts/guides/responsive-and-input-variants.md#hover-active-and-focus-are-per-element),
and pairing it with `transition` tweens the change instead of snapping it:

```tsx title="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](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md), 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:

```tsx title="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](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md), 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

_Interactive preview: 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-full` — `new 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

_Interactive preview: 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](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md) to express a number. Set `Size` directly and
let the classes carry what does not change:

```tsx title="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

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

```tsx title="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

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

```tsx title="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](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md). 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](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md), or
the short version in the
[five rules](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md#five-rules-that-keep-you-on-the-static-path).

## See also

- [Your first component](https://docs.astra-void.xyz/vela-rbxts/getting-started/first-component.md) — the same walkthrough at
  single-utility resolution.
- [Layout and sizing](https://docs.astra-void.xyz/vela-rbxts/guides/layout-and-sizing.md) — the layout model these recipes lean on.
- [Troubleshooting](https://docs.astra-void.xyz/vela-rbxts/guides/troubleshooting.md) — when a recipe does not look like its preview.
