# Your first component

> Build a small panel with Vela utilities and read the Roblox properties and helper instances it lowers to.

Source: https://docs.astra-void.xyz/vela-rbxts/getting-started/first-component/

The fastest way to understand Vela is to write a small component and read what comes out. This page
builds a panel with a title and a caption, then walks through what each class name became.

_Interactive preview: The panel this page builds, compiled by Vela and rendered in the browser._

That preview is not a screenshot. The example was lowered by the same compiler `rbxtsc` loads.
**Classes** is the source with `className` on it, **Lowered** is what came out, and that source is
the snippet below, line for line.

The **Lowered** tab also shows `BackgroundTransparency` and `BorderSizePixel` on elements whose
classes mention neither. That is [preflight](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight). Roblox paints
every `GuiObject` as an opaque grey box, so an element carrying a `className` starts transparent and
borderless unless something paints it.

## The component

```tsx title="src/client/Panel.tsx"
import React from "@rbxts/react";

export function Panel() {
  return (
    <frame className="flex flex-col gap-4 p-4 rounded-lg bg-slate-800 border border-slate-700 w-80 h-24">
      <textlabel
        className="w-full h-6 text-left text-slate-100 text-lg font-semibold"
        Text="Loadout"
      />
      <textlabel
        className="w-full h-5 text-left text-slate-400 text-sm"
        Text="Two slots remaining"
      />
    </frame>
  );
}
```

Every class here resolves at compile time. The one thing that survives into the output is the rem
scaler, which makes those offsets follow the player's viewport. [What comes out](#what-comes-out)
shows it.

> **Why every element carries a size**
>
> `h-24` on the panel and `w-full h-6` on the labels are Roblox defaults showing through, not Vela requirements. A `GuiObject` starts at `Size = (0, 0, 0, 0)`. A class list that only sets a width leaves the height at zero, and the panel compiles cleanly to an invisible frame containing two invisible labels. Use `w-fit`/`h-fit` to lower to `AutomaticSize` instead.

## Put it on screen

Vela changes nothing about how you mount a component. This is ordinary `@rbxts/react-roblox`. The
one Vela-specific detail is the wrapper: `screengui` is **not** one of the eight host elements Vela
lowers, so classes belong on a `frame` inside it.

```tsx title="src/client/main.client.tsx"
import React from "@rbxts/react";
import { createPortal, createRoot } from "@rbxts/react-roblox";
import { Players } from "@rbxts/services";
import { Panel } from "./Panel";

const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui");
const root = createRoot(new Instance("Folder"));

root.render(
  createPortal(
    <screengui ResetOnSpawn={false} ZIndexBehavior={Enum.ZIndexBehavior.Sibling}>
      <Panel />
    </screengui>,
    playerGui,
  ),
);
```

Rojo maps this file into `StarterPlayer/StarterPlayerScripts` like any other client entry point. The
`.client` in the filename is what makes roblox-ts emit a LocalScript. Note the extension too: Vela
only transforms `.tsx` files.

Build, sync, and press play: the panel from the preview above is now in your place.

## What each class lowers to

The `frame` classes split two ways. Some set properties on the Frame. The rest become helper
instances, because Roblox models layout, padding, corners and strokes as separate objects.

| Class | Result | Value |
|---|---|---|
| `flex` | `UIListLayout` child, `FillDirection` | `Enum.FillDirection.Horizontal` |
| `flex-col` | `UIListLayout.FillDirection` | `Enum.FillDirection.Vertical` |
| `gap-4` | `UIListLayout.Padding` | `new UDim(0, 16)` |
| `p-4` | `UIPadding` child, all four sides | `new UDim(0, 16)` |
| `rounded-lg` | `UICorner.CornerRadius` | `new UDim(0, 8)` |
| `bg-slate-800` | `BackgroundColor3` | `Color3.fromRGB(29, 41, 61)` |
| `border` | `UIStroke.Thickness` | `1` |
| `border-slate-700` | `UIStroke.Color` and `Transparency` | `Color3.fromRGB(49, 65, 88)`, `0` |
| `w-80 h-24` | `Size` | `UDim2.fromOffset(320, 96)` |

Every offset in that column is a **rem** value at the base viewport, not a fixed pixel count.
`new UDim(0, 16)` is what `p-4` is worth at 1920×1020. See [rem](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem).

A few of those deserve a note.

`flex` and `flex-col` write to the same instance. Bare `flex` sets `FillDirection` to `Horizontal`,
and `flex-col` overwrites it with `Vertical`. Only one `UIListLayout` is emitted however many layout
classes you stack, so `flex-col` alone is enough for a column.

`gap-4` and `p-4` both resolve `4` through the theme's `spacing` scale, where the built-in default
has its only entry. Every other numeric key comes from an arithmetic fallback: a non-negative
multiple of `0.5`, times four pixels. That is why `w-80` is 320 pixels rather than a lookup failure.

`w-80` and `h-24` merge into one `Size` rather than one replacing the other, producing a single
`UDim2.fromOffset(320, 96)`. Drop the `h-24` and the height falls back to zero, so the panel
disappears. On the runtime path the merge does not happen and the later token wins.

`border` and `border-slate-700` are two utilities sharing a prefix. The bare form sets thickness.
The colour form sets colour and transparency, so `border-slate-700` alone gives a coloured stroke
with no explicit thickness.

The `textlabel` classes are mostly text-only. `text-slate-100` sets `TextColor3`, `text-lg` sets
`TextSize` to `18`, `text-left` sets `TextXAlignment` to `Left`, and `font-semibold` sets
`FontFace`. Their `w-full h-6` goes through the same sizing path as the panel's, mixing a scale and
an offset into `new UDim2(1, 0, 0, 24)`.

`text-*` is disambiguated by value, not prefix. A known size key becomes `TextSize`, an alignment
keyword becomes `TextXAlignment`, and anything else is read as a colour. Misspell a size and you get
a theme-key warning about a colour.

## What comes out

The transformer removes `className`, emits the resolved properties on the element, and prepends the
helper instances ahead of your own children. Here is the emitted TSX, with line breaks added:

```tsx title="Transformed output (line breaks added; nothing else changed)"
import { createVelaRemScaler, __VelaOpacity } from "@rbxts/vela-runtime";
const __VelaRem = createVelaRemScaler({
  base: 16, min: 8, max: 64, baseResolution: { x: 1920, y: 1020 },
});

<frame
  BackgroundColor3={Color3.fromRGB(29, 41, 61)}
  Size={__VelaRem.scale(UDim2.fromOffset(320, 96), 11)}
  BorderSizePixel={0}
>
  <uilistlayout
    FillDirection={Enum.FillDirection.Vertical}
    Padding={__VelaRem.scale(new UDim(0, 16), 4)}
    SortOrder={Enum.SortOrder.LayoutOrder}
  />
  <uipadding
    PaddingTop={__VelaRem.scale(new UDim(0, 16), 5)}
    PaddingRight={__VelaRem.scale(new UDim(0, 16), 6)}
    PaddingBottom={__VelaRem.scale(new UDim(0, 16), 7)}
    PaddingLeft={__VelaRem.scale(new UDim(0, 16), 8)}
  />
  <uicorner CornerRadius={__VelaRem.scale(new UDim(0, 8), 9)} />
  <uistroke Thickness={__VelaRem.scale(1, 10)} Color={Color3.fromRGB(49, 65, 88)} Transparency={0} />
  <textlabel
    Text="Loadout"
    TextXAlignment={Enum.TextXAlignment.Left}
    TextColor3={Color3.fromRGB(241, 245, 249)}
    TextSize={__VelaRem.scaleText(18, 0)}
    Size={__VelaRem.scale(new UDim2(1, 0, 0, 24), 1)}
    FontFace={new Font("rbxasset://fonts/families/SourceSansPro.json", Enum.FontWeight.SemiBold)}
    BorderSizePixel={0}
    BackgroundTransparency={1}
  />
  <textlabel
    Text="Two slots remaining"
    TextXAlignment={Enum.TextXAlignment.Left}
    TextColor3={Color3.fromRGB(144, 161, 185)}
    TextSize={__VelaRem.scaleText(14, 2)}
    Size={__VelaRem.scale(new UDim2(1, 0, 0, 20), 3)}
    BorderSizePixel={0}
    BackgroundTransparency={1}
  />
</frame>
```

Four things there were never in your class list. `BorderSizePixel` and the labels'
`BackgroundTransparency` are [preflight](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight). The panel skips
the transparency because `bg-slate-800` paints it. `SortOrder = LayoutOrder` is emitted on every
Vela layout so `order-*` works.

Every offset is wrapped in `__VelaRem.scale`. That is [rem](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem). The
literal inside is the value at a 1920×1020 viewport, and the trailing number is a binding slot.
`TextSize` goes through `scaleText`, which caps at 100. Pin `theme.rem` and the wrappers disappear,
leaving plain literals.

That is why the **Lowered** tab shows plain literals and this block does not. Every preview in these
docs is compiled with rem pinned, to keep the render one pixel per pixel.

One more thing is not shown above. A component definition's root is wrapped in
`<__VelaOpacity.Fade>`, which is how an `opacity-*` on an ancestor reaches a statically lowered
subtree. It renders no instance.

The helpers are prepended, so they sort ahead of your own children. That does not affect layout, but
it matters if you index children positionally.

This is the **static path**. The element keeps its tag, no class list is parsed in game, and every
token was resolved and checked at build time. The
[pipeline page](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md) explains when that stops being true.

## Something that does not work

Vela is a Roblox-shaped subset of Tailwind, not a port. The Roblox text engine exposes no letter
spacing, so `tracking-*` cannot mean anything.

```tsx title="This warns"
<frame className="tracking-wide p-4 bg-slate-800" />
```

The build still succeeds, but the transformer reports a warning through roblox-ts:

```text title="Compiler output"
[@vela-rbxts/compiler] no-roblox-equivalent: Tailwind "tracking" utilities have no Roblox equivalent, so "tracking-wide" is ignored.
```

`p-4` and `bg-slate-800` are applied normally. Only the inexpressible token is dropped. A class Vela
has never heard of gets `unsupported-utility-family` instead, so the two failure modes are
distinguishable.

Colours have their own failure mode. You reach a shade by naming it or through the palette's
`DEFAULT`. A palette with neither is an error rather than a fallback. Say a `brand` family defines
only `500` and `700`:

```tsx title="This warns too"
<frame className="bg-brand" />
```

```text title="Compiler output"
[@vela-rbxts/compiler] color-missing-shade: Color palette "brand" for background color utility has no "DEFAULT" shade, so it requires an explicit shade such as "brand-500" in className literal.
```

Write `bg-brand-500`, or add a `DEFAULT` key. The built-in palettes all ship one mirroring their
`500`, which is why `bg-slate` needs no shade while `bg-brand` does.

> **Where the squiggle lands**
>
> For a static string `className` the compiler anchors each diagnostic to the token's real position. A computed `className` produces diagnostics with no range. The host adapter then falls back to the first textual occurrence of the token anywhere in the file, so the underline can land on an earlier comment or string. Diagnostics carrying no token at all are reported at the start of the file. The message and code are always correct.

The full list of codes, and what each one means, is in the [diagnostics reference](https://docs.astra-void.xyz/vela-rbxts/reference/diagnostics.md).

## Next step

Read [How it works](https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works.md) for how the transformer fits into an
`rbxtsc` build, and what makes it switch to a runtime path.
