# Component conventions

> The ten rules every registry component follows — each one correcting a web instinct that is wrong on Roblox.

Source: https://docs.astra-void.xyz/facet/guides/component-conventions/

These are the conventions every component in the registry follows. They matter to you for two
reasons: you are going to edit the copied files, and you are going to write components of your own
next to them.

Most of them exist because a web instinct is wrong here. Each one says which instinct it corrects.

## 1. Declare both axes. Always.

Vela resolves a frame's size starting from `UDim2.new(0, 0, 0, 0)`. Nothing infers a size from
content unless you ask for it, and **`UIPadding` does not grow a frame** — it insets its children.

So `h-9 px-4`, a perfectly good shadcn button, renders **zero pixels wide**. That is not
hypothetical; it is what the first published `button` did.

```
h-9 px-4        → 0 × 36. Invisible.
h-9 w-fit px-4  → hugs its content. Correct.
h-9 w-9         → fixed square. Also correct.
```

Every leaf declares a width *and* a height: a concrete class on each axis, or `w-fit` / `h-fit` /
`size-fit` for the axis that should hug.

## 2. `AutomaticSize` is a chain

A container set to hug its content can only measure children that already know their own size. One
child with an unresolved axis and the parent collapses.

When a component nests — button → label, card → header → title — **every level** needs an answer,
not just the outermost one. `card` is the clearest example: every part carries `w-full h-fit`, width
from the parent and height from the content, all the way down.

## 3. Nothing inherits

There is no cascade. `text-sm` on a button does not reach the label inside it; text properties belong
to the instance that draws the text.

The consequence: **a component with a label needs a second recipe for that label.**
`buttonVariants` sizes the button, `buttonLabelVariants` styles the text, both keyed off the same
`size` prop. Covered in [Text and labels](https://docs.astra-void.xyz/facet/guides/text-and-labels.md#a-label-needs-its-own-recipe).

## 4. Text arrives as a prop, renders as a child instance

`Text?: string` on every component that draws a string; `children` stays composition. `~/lib/text`'s
`TextSlot` picks between them. See
[Text and labels](https://docs.astra-void.xyz/facet/guides/text-and-labels.md) for the whole story, including why the prop is
uppercase and why `TextSlot` takes no `className`.

**Every text recipe declares a `font-*`** — rule 1 in a different costume. Vela leaves `FontFace`
untouched when no `font-*` token appears, and Roblox's untouched default is LegacyArial: a different
typeface, not a weight.

## 5. Layout is an instance, not a property

`flex-row`, `items-*`, `justify-*` and `gap-*` all lower onto a single `UIListLayout` child, and
**one instance can hold one layout**.

A component that sets any of them owns the arrangement of its children. A consumer who wants a
different one has to *replace* the component's layout classes rather than add to them.

Corollary: do not add a wrapper frame just to get a second layout. Restructure the parts instead —
which is something you can do freely, because the file is yours.

The general form of that corollary is **do not add an instance to carry what an existing instance
can carry**, and it is why the registry has no `AspectRatio` component:

```tsx
<imagelabel className="w-full aspect-video" Image={thumbnail} />
```

CSS could not constrain a box's ratio for most of its life, so Radix packaged the
`padding-bottom: 56.25%` workaround and shadcn re-exported it — the component exists to hide a
trick. Roblox has `UIAspectRatioConstraint` and Vela lowers straight onto it (`aspect-square`,
`aspect-video`, `aspect-[4/3]`, `aspect-[1.5]`). A wrapper would add a frame to the tree, a file to
your project, and a level to the `AutomaticSize` chain, to deliver something you can already write
on the element you have — and it would be strictly less capable, since the class works on any host
instance while a component forces whatever it wraps to sit inside a `Frame`.

**Ratio is a class, not a component.**

## 6. Flat named exports

`Card`, `CardHeader`, `CardTitle` — not `Card.Root`. Lattice uses namespace objects and that is
right for a library; this is source someone pastes and edits, so it matches shadcn, where each part
reads independently and can be deleted on its own.

Facet components wrap Lattice's namespaces rather than re-exporting them:

```tsx
import { Dialog as DialogPrimitive } from "@lattice-ui/react-dialog";

export const Dialog = DialogPrimitive.Root;
export function DialogContent(props: DialogContentProps) { /* styled */ }
```

> **One recipe object per file — and this is a constraint, not a preference**
>
> The *parts* stay flat, but the **recipes do not**: a component file exports one recipe object, not
> one export per part.
>
> Luau allows 200 local registers per function and a module body is a function, so every
> module-scope `local` in the emitted Luau costs one — including every `export const`, which roblox-ts
> lowers to a local plus an assignment. Vela then inlines its whole runtime into any file with a
> computed `className`, and every Facet class string comes out of `fv()`, so every component file
> carries that copy.
>
> In Vela 0.8.0 the copy declared ~96 module-scope locals before the file's own first line. `card` —
> six exported recipes — went over 200 and stopped loading, with an error naming generated code its
> author never wrote:
>
> ```
> Out of local registers when trying to allocate CardHeader
> ```
>
> Not a compile error; the module simply failed at load. Six `export const`s cost six registers, one
> object costs one — which is why `card` and `alert` both export a single object.
>
> Vela 0.9.0 scopes the inlined runtime into one initializer and emitted components now sit around
> 18–24 module-scope locals, which is why `^0.9.0` is the floor the CLI installs. The headroom is much
> larger. It is still finite, and it is measurable:
>
> ```bash
> grep -c "^local " out/shared/ui/card.luau
> ```
>
> A number in the twenties is normal. A number near 200 means the next part you add to that component
> is the one that breaks it.

## 7. Icons are text glyphs, replaceable by slot

Roblox has no icon font and no free lunch: shipping images means uploading assets to somebody's
account and owning the moderation and licensing forever. Facet renders `▾`, `✓`, `✕` as text and
exposes the slot, so a project that wants real artwork passes its own through `children`.

## 8. Roles, never ramp steps

`bg-muted`, not `bg-zinc-800`. This is what makes `facetTheme({ base: "slate" })` retheme every
copied component without editing one of them.

Enforced by review, not by tooling — the class strings are just text. If changing your theme base
requires editing a component, this rule was broken somewhere in it.

## 9. `ClassValue` is a reserved name

Vela's inlined runtime declares a local `ClassValue`, so a component importing that name gets
`TS2440: Import declaration conflicts with local declaration`. `~/lib/utils` re-exports it as
**`ClassName`**. Use that.

## 10. Every import is declared

An import a component makes must appear in the registry entry as a `dependency` (npm) or a
`registryDependency` (another item). An undeclared one ships a file that cannot compile in a project
that did not happen to have the package already.

This one only binds you if you are [running your own registry](https://docs.astra-void.xyz/facet/guides/custom-registry.md) —
but if you are, `registry:check` catches unresolvable registry dependencies and cannot catch a
missing npm one. That part is on the author.

## Anatomy

Everything above, in one file:

```tsx
import { fv, type VariantProps } from "@facet-ui/react-variants";
import { getPassthroughProps, React } from "@lattice-ui/react-runtime";
import { TextSlot } from "~/lib/text";
import { cn } from "~/lib/utils";

// 1. geometry + surface on the root, both axes resolved
export const thingVariants = fv("flex-row items-center w-fit h-9 rounded-md", {
  variants: { /* … */ },
});

// 2. a matching recipe for any text this component draws itself
export const thingLabelVariants = fv("font-medium text-foreground", {
  variants: { /* … */ },
});

export type ThingProps = VariantProps<typeof thingVariants> & { Text?: string };

const OWN_PROPS = ["variant", "size", "className", "Text", "children"] as const;

// 3. neutralize Roblox's own look, never the consumer's
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0 };

export function Thing(props: ThingProps) {
  const passthrough = getPassthroughProps<Frame>(props, OWN_PROPS);

  return (
    <frame
      className={thingVariants({ variant: props.variant, className: props.className })}
      {...NEUTRAL_PROPS}
      {...passthrough}
    >
      <TextSlot Text={props.Text} className={thingLabelVariants({ size: props.size })}>
        {props.children}
      </TextSlot>
    </frame>
  );
}
```

## Wrapping a Lattice primitive

Everything above holds for a component that is a recipe plus a host element. Twelve of the
components added in 0.4.0 sit on a Lattice primitive instead, and three more rules came out of
building them — written once here rather than twelve times in the files.

### A. State you style by is mirrored, not reached for

Lattice keeps its contexts **private**. `Checkbox.Root` knows whether it is checked; nothing outside
the primitive can read that. But the border and the fill are the wrapper's job, so the wrapper needs
the same answer.

The way out is not to reach in. Hold the value in the copied file with `useControllableState` — the
same hook the primitive uses — and drive the primitive **controlled** from it:

```tsx
const [checked, setChecked] = useControllableState<CheckedState>({
  value: props.checked,
  defaultValue: props.defaultChecked ?? false,
  onChange: props.onCheckedChange,
});

<CheckboxPrimitive.Root checked={checked} onCheckedChange={setChecked} className={…}>
```

One copy of the state, and it lives in the file you own. Where a component does *not* style by a
state, there is no mirror: [`progress`](https://docs.astra-void.xyz/facet/components/progress.md) maps a number to a width and
[`radio-group`](https://docs.astra-void.xyz/facet/components/radio-group.md#half-the-state-is-mirrored-half-is-not)'s inner dot is
mounted and unmounted by the primitive.

The cost is that the mirror only knows what it was told. `Tabs.Root` falls back to the first enabled
trigger when it has no value, and the mirror cannot see that decision — so
[`tabs` needs a `defaultValue`](https://docs.astra-void.xyz/facet/components/tabs.md#pass-defaultvalue) or nothing styles as
selected.

### B. A `className` on a primitive call site has to be an attribute

Vela rewrites the call sites it can **see**. Written as an attribute, `className` is resolved. Folded
into a shared spread, it is just a key in an object — it reaches the primitive as a raw string prop,
is dropped in silence, and the component renders unstyled with no diagnostic.

```tsx
// resolved
<ToggleGroupPrimitive.Root className={className} type="single" {...passthrough} />

// dropped, silently
<ToggleGroupPrimitive.Root type="single" {...{ className, ...passthrough }} />
```

[`toggle-group`](https://docs.astra-void.xyz/facet/components/toggle-group.md#classname-is-written-out-on-both-branches) is where
this bit, because it renders its root twice and the shared spread was the obvious tidy-up.

### C. Where the primitive owns a property, the recipe stays off it

A primitive that writes `Size` or `Position` every frame is as load-bearing as the `UIListLayout`
that owns one, and rule 5 applies to both. Three components omit a class they would otherwise be
required to have:

| Component | What it omits | Who owns it |
| --- | --- | --- |
| [`slider`](https://docs.astra-void.xyz/facet/components/slider.md#the-track-has-no-flex--and-that-is-the-point) | `flex-*` on the track | the range's fill and the thumb's travel |
| [`switch`](https://docs.astra-void.xyz/facet/components/switch.md#the-thumb-has-no-position) | any position on the thumb | `Switch.Thumb`, which animates it |
| [`textarea`](https://docs.astra-void.xyz/facet/components/textarea.md#the-input-declares-no-height) | a height on the input | `Textarea.Input`, which grows it by row |

The failure mode is the one that looks like it works: a declared width on
[`progress`](https://docs.astra-void.xyz/facet/components/progress.md#the-indicator-has-no-width)'s indicator renders correctly on
mount and is overwritten as soon as the value moves.

### D. A forwarded prop bag crosses a boundary widened

`toSlotProps` is the crossing point. The typed passthrough bag collides with the runtime host's `ref`
and with primitives that type `children` as a *single* element — what `asChild` merges onto — so
several files add a local `forwardProps` that widens the bag and drops `children` from its type
alone. The bag never carries one; `children` is always an own prop.

## Wrapping a layered primitive

[`dialog`](https://docs.astra-void.xyz/facet/components/dialog.md) is the first, and three more things came out of it.

**A `PortalProvider` has to be above the app.** `Dialog.Portal` reads a strict context for the
`BasePlayerGui` it renders into. Missing it *throws on open* rather than rendering nowhere — which is
the better of the two failures, and still the only build-clean runtime failure in the registry. The
component declares it, so `facet add` can [offer to write it](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider)
and `facet doctor` can notice when it goes missing.

**The styled panel is a frame inside the primitive's content, never the content itself.** The
primitive forces `Size` on its own host so the layer spans the screen, *and* takes the first host
element under it as the boundary an outside press is measured against. A `className` there fights the
first and — through the `UICorner` Vela prepends for `rounded-*` — quietly becomes the second.

**A class forwarded to another component has to reach one `className` expression.** Vela resolves a
`className` at the call site and hands the component the resolved *properties*, so a class routed
through a wrapper's `className` prop is overwritten by that wrapper's own recipe. `dialog` spells the
prop [`overlayClassName`](https://docs.astra-void.xyz/facet/components/dialog.md#overlayclassname-not-classname) and merges it
where the overlay actually resolves. It is the
[`TextSlot` trap](https://docs.astra-void.xyz/facet/guides/text-and-labels.md#textslot-takes-no-classname) one level up.

## Two things about spread order

**Neutral defaults first.** Roblox instance defaults are themselves a look — a bare `textbutton` is
an opaque grey box labelled "Button". `BackgroundTransparency`, `BorderSizePixel`, `Text` and
`AutoButtonColor` get cleared before anything visual is applied.

**Then consumer passthrough, then behavior props.** Behavior is never overridable — event handlers
are *composed* rather than replaced:

```tsx
Event: composeEvents(passthrough.Event, { Activated: handleActivated })
```

Appearance is a weaker promise than the ordering suggests. Vela emits a component's class-derived
props *after* every spread, whatever order the author wrote them in, so a consumer's passthrough
value loses to anything the recipe sets. What survives is any property the classes do not touch —
`LayoutOrder`, `Position`, `Visible`, `ZIndex`. See
[Overriding from the call site](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).

## Readability is a feature

Prefer a readable 60-line component over a clever 20-line one. The consumer reads this code — it is
the product, not an implementation detail. A copied file that is hard to edit has failed at the one
thing the copy-in model is for.
