# Facet
> Copy-in UI components for roblox-ts, composed from Lattice UI and Vela.
- Docs: https://docs.astra-void.xyz/facet/
- Source: https://github.com/astra-void/facet
# Facet
> Copy-in UI components for roblox-ts, composed from Lattice UI and Vela.
Source: https://docs.astra-void.xyz/facet/
Facet is not a component library. You do not install a `Button` — you run one command and a
`button.tsx` appears in your project, imports resolved and theme wired up. It is yours from that
moment: edit it, delete half of it, rename it. Nothing will overwrite your changes, because nothing
ever updates it.
What makes that model work is that the hard parts are not in the copied file. Behavior — focus,
layering, presence, controlled state, `asChild` — comes from [Lattice UI](https://docs.astra-void.xyz/lattice-ui/index.md), which is a
real dependency. Styling comes from [Vela](https://docs.astra-void.xyz/vela-rbxts/index.md), which lowers `className` to Roblox
properties at compile time, so a component holds no `Color3` and no `UDim2` of its own. A Facet
component is a Lattice primitive wearing Vela classes; the file you own is the composition, and it
is short enough to read in one sitting.
Two small packages and a CLI are all that is published. `@facet-ui/react-variants` is the `cva`
equivalent every component imports, `@facet-ui/theme` supplies the semantic tokens as a Vela config
preset, and `facet-rbxts` fetches components from a
[hosted registry](https://facet.astra-void.xyz) rather than bundling them — so adding a component
does not require a CLI release.
---
# Installation
> What Facet publishes, what your project has to already be, and what facet init writes.
Source: https://docs.astra-void.xyz/facet/getting-started/installation/
Facet publishes three packages, and **the components are not among them**. They are text on a
static registry that the CLI fetches and copies into your project. You install the CLI; it installs
the other two at the moment they are actually needed.
```bash
pnpm add -D facet-rbxts
```
Or skip installing it at all — every command works through `npx`:
```bash title="No install"
npx facet-rbxts init
npx facet-rbxts add button
```
**The three packages, and which one arrives when**
| Package | What it is | Installed as |
| --- | --- | --- |
| `facet-rbxts` | The CLI. Command is `facet`. Node, dev dependency. | `-D`, or `npx` |
| `@facet-ui/theme` | Semantic tokens shaped as a Vela config preset. Only `vela.config.ts` imports it. | `-D`, by `facet init` |
| `@facet-ui/react-variants` | `fv()` and `cn()` — the one runtime package copied components import. | runtime, by `facet add` |
All three are versioned in lockstep (`fixed` in the changesets config), published under MIT, and
currently at **0.4.0**.
## What your project has to be first
Facet copies files into a project; it does not create one. Before `facet init` is useful, the
project needs to be a [roblox-ts](https://roblox-ts.com/) project that already builds, with three
things in place:
| Requirement | Why |
| --- | --- |
| **`@rbxts/react`** | Components are `@rbxts/react` function components. Vide is not supported. |
| **[Vela](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md), with its transformer registered in `tsconfig.json`** | Every visual property a component has arrives through `className`. Without the transformer the build still succeeds and the UI comes out completely unstyled. |
| **A `package.json` at the project root** | `facet` walks up from the current directory to the nearest one and treats it as the project root. |
Lattice is *not* on that list: `facet add` installs the Lattice packages a component needs, because
the registry entry declares them.
> **The transformer is the failure nobody notices**
>
> `facet init` and `facet doctor` both check `tsconfig.json` for `vela-rbxts/transformer` — textually,
> because roblox-ts tsconfigs are routinely JSONC. Neither of them edits it. If the check fails, add
> it yourself:
>
> ```json title="tsconfig.json"
> {
> "compilerOptions": {
> "plugins": [{ "transform": "vela-rbxts/transformer" }]
> }
> }
> ```
>
> Without it, `className` is inert. Nothing errors. You get a screen of grey Roblox defaults and no
> explanation.
**Version floors: why the CLI pins vela-rbxts@^0.9.0**
The CLI installs `vela-rbxts@^0.9.0` as a build dependency, and that floor is not decorative:
- **Below 0.7.0**, `w-fit` and `font-*` silently do nothing on the computed-`className` path that
every `fv()` recipe produces — which is how the first published `button` shipped zero pixels wide.
- **Below 0.8.0**, `opacity-*`, `whitespace-*` and `leading-*` join them.
- **Below 0.9.0**, `card` does not compile *at all*. Vela 0.8.0 inlined its runtime into every
transformed file, spending roughly 96 of Luau's 200 local registers before the file declared
anything of its own; `card` failed with `Out of local registers` pointing at generated code nobody
wrote. 0.9.0 scopes that runtime into a single initializer.
The caret is npm's 0.x caret, so `^0.9.0` means `>=0.9.0 <0.10.0` — a floor on a fresh `init` and a
ceiling on the next Vela minor. That is deliberate while Vela is pre-1.0 and every minor so far has
moved class resolution. Raising it is a CLI release, which is the point at which the registry has
actually been built against the new minor.
Registry components separately declare `@lattice-ui/react-runtime@^0.8.0`. 0.8.0 is what made
`asChild` work at all — see [Components](https://docs.astra-void.xyz/facet/components/button.md#aschild).
A component that wraps a Lattice primitive declares that primitive's package at the same floor —
`@lattice-ui/react-checkbox@^0.8.0`, `@lattice-ui/react-dialog@^0.8.0`, and so on — and `facet add`
installs it when the component is copied. Every entry carries the *same* spec for a given package,
because `add` unions those strings across the install set and two spellings would both reach the
package manager. `registry:check` enforces it; the
[registry format reference](https://docs.astra-void.xyz/facet/reference/registry-format.md#validation-rules) has the rule.
## `facet init`
```bash title="Set the project up"
npx facet-rbxts init
```
It asks four things (or takes the defaults with `-y`), then does five:
1. **Writes `facet.json`** — theme base and mode, where components land, which registry to read.
See the [`facet.json` reference](https://docs.astra-void.xyz/facet/reference/facet-json.md).
2. **Creates `vela.config.ts` if there is none**, pre-wired with `facetTheme()`. If one already
exists it is never rewritten — only reported on, with the exact lines to add.
3. **Installs the build dependencies** — `@facet-ui/theme` and `vela-rbxts@^0.9.0`, as dev
dependencies.
4. **Adds the `utils` registry item**, because every component imports `~/lib/utils`. That also
pulls in `@facet-ui/react-variants`.
5. **Reports on `tsconfig.json`** — the transformer check above.
**The defaults, if you pass -y**
```json title="facet.json"
{
"$schema": "https://facet.astra-void.xyz/schema.json",
"style": "default",
"theme": { "base": "zinc", "mode": "dark" },
"aliases": {
"ui": { "dir": "src/shared/ui" },
"lib": { "dir": "src/shared/lib" },
"hooks": { "dir": "src/shared/hooks" }
},
"velaConfig": "vela.config.ts"
}
```
No `import` specifier is set on those aliases by default, which means copied files reach each other
through **relative** imports. That needs no tsconfig `paths` and therefore works in a project nobody
configured for this. If your project already has an alias, answer the prompt with it and the CLI
writes `shared/ui`-style specifiers instead.
**Why init creates files but never edits them**
Both `vela.config.ts` and `tsconfig.json` belong to you and are routinely non-trivial — JSONC,
comments, spreads, plugins, computed values. A pattern-matched edit that mangles one is worse than a
printed snippet, so `init` prints.
There is now exactly one file the CLI *does* edit, and it took the real parser that condition
implied: `facet add` wraps your client entry in the providers a component declares, using
`@babel/parser` for positions and string splices for the edit. It is behind a prompt, and it
happens in `add` rather than `init` — see [wiring a provider](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider).
## Verify
```bash title="Smoke test"
npx facet-rbxts doctor
```
`doctor` is the one command that checks the whole setup rather than one part of it: the config, the
import aliases, the transformer, the theme, which components are installed, whether the tokens they
name resolve, and whether the packages underneath them meet the floors those files need. A clean run
means a copied component will compile and look like it is supposed to.
Then add something and build:
```bash title="First component"
npx facet-rbxts add button
npx rbxtsc
```
See [Your first component](https://docs.astra-void.xyz/facet/getting-started/first-component.md) for what to do with it.
## Next step
- [Your first component](https://docs.astra-void.xyz/facet/getting-started/first-component.md) — render a `Button`.
- [How it works](https://docs.astra-void.xyz/facet/getting-started/how-it-works.md) — what `add` actually does to the files.
- [Scope and status](https://docs.astra-void.xyz/facet/getting-started/scope-and-status.md) — what exists at 0.4.0, and what does not.
---
# Your first component
> Copy a button in, render it, and read the three things about it that are not the shadcn version.
Source: https://docs.astra-void.xyz/facet/getting-started/first-component/
This assumes a roblox-ts project that already builds, with Vela's transformer registered and
`facet init` run once — see [Installation](https://docs.astra-void.xyz/facet/getting-started/installation.md).
## Add it
```bash title="One component, three files"
npx facet-rbxts add button
```
```
✔ write src/shared/lib/utils.ts
✔ write src/shared/lib/text.tsx
✔ write src/shared/ui/button.tsx
```
Three files for one component, because `button` declares `utils` and `text` as registry
dependencies and `add` resolves those transitively. `utils` was already there if you ran `init`, in
which case it is listed as `exists` and left alone — `add` never overwrites without `--overwrite`.
The npm packages the entry declares (`@facet-ui/react-variants`, `@lattice-ui/react-runtime`) are
installed at the same time, through whichever package manager your lockfile implies. `--no-deps`
skips that; `--dry-run` resolves and reports without writing anything.
## Render it
```tsx title="src/client/main.client.tsx"
import React, { StrictMode } from "@rbxts/react";
import { createPortal, createRoot } from "@rbxts/react-roblox";
import { Players } from "@rbxts/services";
import { Button } from "../shared/ui/button";
function App() {
return (
print("saved")} />
);
}
const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui");
const root = createRoot(new Instance("Folder"));
root.render(
{createPortal(
,
playerGui,
)}
,
);
```
```bash title="Build"
npx rbxtsc
```
## The three things that are not shadcn
If you have written shadcn/ui, two of these will bite you within the hour.
### 1. Text is a prop, not children
```tsx
Save // TS2747. Not a Facet choice.
// this
```
roblox-ts React's `ReactNode` has no string member — host instances draw text from a `Text`
property rather than from a text node — so a bare string child is a type error no matter what the
component declares. `children` keeps its shadcn meaning: composition, for an icon or a nested
element beside the label.
The prop is spelled uppercase, matching the Roblox instance property it shadows. That is the point:
`Text` is already a member of `PassthroughProps`, so if Facet declared a lowercase
`text` instead, a developer writing the obvious ` ` would have it land on the
host instance and draw Roblox's 8px near-black default *underneath* the styled label. Declaring
`Text` intercepts it. See [Text and labels](https://docs.astra-void.xyz/facet/guides/text-and-labels.md).
### 2. Both axes, always
`UIPadding` does not grow a frame on Roblox — it insets children. And Vela resolves a frame's size
starting from `UDim2.new(0, 0, 0, 0)`, so nothing hugs its content unless you ask.
```
h-9 px-4 → 0 × 36 pixels. Invisible.
h-9 w-fit px-4 → hugs its content. Correct.
h-9 w-9 → fixed square. Also correct.
```
`h-9 px-4` is a perfectly good shadcn button and renders nothing here. This is why `buttonVariants`
carries `w-fit` in its base classes, and why the `icon` size overrides it with a concrete `w-9`.
Every class string you write in a copied component needs an answer on both axes.
### 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.
That is why `button.tsx` exports *two* recipes:
```tsx
export const buttonVariants = fv("… w-fit rounded-md", { /* geometry + surface */ });
export const buttonLabelVariants = fv("font-medium", { /* colour + size of the text */ });
```
Both key off the same `variant` and `size` props. It is the single biggest structural difference
from shadcn, where one class list on the parent styles everything under it.
> **Every text recipe declares a font-***
>
> Rule 2 in another costume. Vela leaves `FontFace` untouched when no `font-*` token appears, and
> Roblox's untouched default is **LegacyArial** — not a weight of the font every other label resolves
> to, a different typeface, visibly larger at the same `TextSize`. `card`'s description had no
> `font-*` and rendered in Arial next to a SourceSansPro title, in the same header, for as long as
> nobody had looked at it. Weight is not optional styling here; it is the only thing that says *which
> font*.
## Now edit it
That is the whole point of the model. `src/shared/ui/button.tsx` is a file in your repository:
change the variants, drop the ones you do not use, rename the component. Facet has no mechanism to
push an update over it, and no record that it ever gave it to you.
Editing is also the *only* override that reliably works. A `className` passed to a Facet component
from outside is consumed by Vela at the call site and then overwritten by the component's own
recipe — measured, silent, and covered in
[Overriding from the call site](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
The one thing to preserve while you edit the class strings is **ordering discipline** — nothing may
be appended after `props.className` inside the component, because Vela's resolution is
last-token-wins. See [Variants and classes](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#the-one-rule).
## Next step
- [How it works](https://docs.astra-void.xyz/facet/getting-started/how-it-works.md) — the registry, the rewrite, the layers.
- [Button](https://docs.astra-void.xyz/facet/components/button.md) — every prop, variant, and the `asChild` caveat.
- [Theming](https://docs.astra-void.xyz/facet/guides/theming.md) — retheme all of it without editing a component.
---
# How it works
> The three layers, the hosted registry, and exactly what facet add does to a file between the registry and your project.
Source: https://docs.astra-void.xyz/facet/getting-started/how-it-works/
## Three layers, and Facet is the thinnest
| Layer | Owns | Package |
| --- | --- | --- |
| **[Lattice UI](https://docs.astra-void.xyz/lattice-ui/index.md)** | Behavior — focus, layering, presence, portals, controlled/uncontrolled state, `asChild` | `@lattice-ui/react-*` |
| **[Vela](https://docs.astra-void.xyz/vela-rbxts/index.md)** | Styling — `className` lowered to Roblox properties at compile time | `vela-rbxts` |
| **Facet** | The opinionated composition of the two, as source you own | the registry |
The rule that follows: **do not reimplement in a copied component what Lattice or Vela already
owns.** If a Facet component grows focus management, the fix belongs upstream in the Lattice
primitive. If it grows a literal `Color3` or `UDim2`, it has stopped being themeable.
That division is what makes the copy-in model viable at all. The file you own is composition —
recipes, a host element, prop plumbing — and it is short because everything difficult sits in a
package that *is* a dependency and *does* get updated.
## The registry is fetched, not bundled
`registry/` in the Facet repo builds to a static site on GitHub Pages, and the CLI reads it over the
network at runtime:
```
https://facet.astra-void.xyz/
schema.json the JSON Schema every facet.json names in its own $schema
r/index.json the index the CLI reads first
r/button.json one payload per component, source text inlined
```
Two consequences, both intentional:
- Adding a component to Facet means publishing the registry, not releasing the CLI.
- `facet add button` produces the same files for everyone, rather than whatever was frozen into the
CLI version they happen to have installed.
The cost is that `add` needs a network. That is the same trade shadcn/ui makes, and the failure is
loud rather than silently stale. You can point elsewhere — a fork, a private registry, a local build
— see [Using another registry](https://docs.astra-void.xyz/facet/guides/custom-registry.md).
The index carries a `version` field, currently `1`. A CLI that meets a higher one refuses to guess
and tells you to upgrade.
## What `add` does to a file
Between the registry payload and your project, exactly one transformation happens: **`~/` imports
are rewritten.**
Registry sources address each other with a `~/` prefix — never a relative path — because the CLI
can rewrite `~/lib/utils` and cannot rewrite `../lib/utils`:
```tsx title="registry/src/ui/button.tsx — as authored"
import { TextSlot } from "~/lib/text";
import { cn } from "~/lib/utils";
```
The first segment names an alias (`ui`, `lib`, `hooks`), which `facet.json` maps to a directory. How
the rewrite lands depends on whether that alias has an `import` specifier:
```tsx title="No import specifier — the default"
import { TextSlot } from "../lib/text";
import { cn } from "../lib/utils";
```
```tsx title="aliases.lib.import = \"shared/lib\""
import { TextSlot } from "shared/lib/text";
import { cn } from "shared/lib/utils";
```
Relative is the default because it needs no tsconfig `paths` and therefore works in a roblox-ts
project nobody configured for this. The relative path is computed from where the *importing* file
lands, so moving your `ui` directory does not break the `lib` imports inside it.
An unknown alias segment is left alone deliberately, so it fails loudly at typecheck rather than
being silently rewritten to something wrong.
Nothing else is touched. No formatting, no codemod, no injected header. The file on disk is the
registry's text with those specifiers swapped.
### Where files land
A registry file's path is `/`, and the item's type decides the alias:
| Item type | `facet.json` alias | Default directory |
| --- | --- | --- |
| `registry:ui` | `ui` | `src/shared/ui` |
| `registry:lib` | `lib` | `src/shared/lib` |
| `registry:hook` | `hooks` | `src/shared/hooks` |
| `registry:block` | `ui` | `src/shared/ui` |
`dir` is a real path from the project root rather than a module specifier, because roblox-ts
projects are laid out by Rojo, not by module resolution.
### One transaction
Every write goes through a file transaction that commits or rolls back as a unit. A failure halfway
through `facet add card` does not leave you with a `card.tsx` whose helpers were never written.
### And, for one kind of component, one edit to a file you wrote
An item may declare [`providers`](https://docs.astra-void.xyz/facet/reference/registry-format.md#providers) — a React provider it
needs above your whole app. Today that is [`dialog`](https://docs.astra-void.xyz/facet/components/dialog.md), which cannot find a
`PlayerGui` to portal into without one.
This is the only place the CLI touches a file it did not write, and it asks first. The reason it is
worth the exception: everything else the CLI reports fails at *build* time, loudly, in front of the
person who just ran the command. A missing provider fails at runtime, in production. See
[wiring a provider](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider).
## What the copied component then depends on
A Facet component may only import from four places, and this is enforced by review in the Facet
repo because anything else will not resolve once copied:
- `@rbxts/*`
- `@lattice-ui/*`
- `@facet-ui/react-variants`
- `~/...` — other registry files
Every one of those imports must be declared in the registry entry as a `dependency` (npm) or a
`registryDependency` (another item). An undeclared import ships a file that cannot compile in a
project that did not happen to have the package already.
## Where the theme actually lives
Roblox has no CSS variables, so the indirection that makes shadcn/ui themeable lives one layer down
— in `vela.config.ts`:
```ts title="vela.config.ts"
import { defineConfig } from "vela-rbxts";
import { facetTheme } from "@facet-ui/theme";
export default defineConfig({
theme: {
extend: {
...facetTheme({ base: "zinc", mode: "dark" }),
},
},
});
```
Components name **roles** (`bg-primary`, `text-muted-foreground`), never ramp steps (`bg-zinc-900`).
`facetTheme()` maps those roles onto a neutral ramp, and Vela resolves them to literal `Color3`
values at compile time. Switching `base` rethemes every copied component without touching one of
them.
> **A build carries exactly one mode**
>
> Because resolution happens at compile time, `mode: "dark"` is a build-time choice, not a runtime
> toggle. shadcn/ui gets runtime theming free because the browser re-reads CSS variables on every
> paint; Roblox has no equivalent indirection. See
> [Theming](https://docs.astra-void.xyz/facet/guides/theming.md#one-mode-per-build) for what the options actually are.
## What Facet does not do
- **It does not record anything at copy time.** No lock file, no content hashes, no note of which
registry version a file came from. `facet add` writes and forgets. That is why `facet diff` can
show that a file differs but cannot say whether *you* changed it or upstream did — see
[Updating copied components](https://docs.astra-void.xyz/facet/guides/updating-copied-components.md).
- **It does not update.** There is no `facet upgrade`. The most the CLI can do is show you a diff
and let you decide.
- **It does not have a second style.** `facet.json` keeps shadcn's `style` field, pinned to
`"default"`, and nothing in the CLI branches on it. It stays only because removing a key from a
file consumers commit is a breaking change for the benefit of deleting one line.
---
# Scope and status
> What exists at 0.4.0, what has actually been looked at in Studio, and which decisions are still open.
Source: https://docs.astra-void.xyz/facet/getting-started/scope-and-status/
Facet is early. The whole chain works end to end — all three packages are on npm, the registry is
live, and `npm i -D facet-rbxts` → `facet init` → `facet add button` → `rbxtsc` compiles in a
project set up from scratch — and the registry now holds twenty-one components. What has *not* been
checked is how most of them look.
## What ships at 0.4.0
**Every command the CLI advertises is written.** `list`, `init`, `add`, `remove`, `diff`, `doctor` —
all implemented, all covered by tests that put a fixture project through them offline against a
registry built into a temporary directory. See the [CLI reference](https://docs.astra-void.xyz/facet/reference/cli.md).
**The registry holds twenty-three items** — twenty-one components and the two helpers they import.
The **pure-recipe tier** is a recipe plus a host element, with no Lattice primitive underneath:
| Item | |
| --- | --- |
| [`alert`](https://docs.astra-void.xyz/facet/components/alert.md) | three parts, default or destructive — and the variant goes on each |
| [`badge`](https://docs.astra-void.xyz/facet/components/badge.md) | status pill that hugs its label |
| [`card`](https://docs.astra-void.xyz/facet/components/card.md) | six flat parts — root, header, title, description, content, footer |
| [`kbd`](https://docs.astra-void.xyz/facet/components/kbd.md) | key cap, and the registry's one deliberate typeface |
| [`label`](https://docs.astra-void.xyz/facet/components/label.md) | form label |
| [`separator`](https://docs.astra-void.xyz/facet/components/separator.md) | one-pixel divider, either orientation |
| [`skeleton`](https://docs.astra-void.xyz/facet/components/skeleton.md) | placeholder block, deliberately without a pulse |
The **one-primitive tier** wraps a single Lattice primitive, new in 0.4.0:
| Item | |
| --- | --- |
| [`accordion`](https://docs.astra-void.xyz/facet/components/accordion.md) | collapsible items, single or multiple open |
| [`avatar`](https://docs.astra-void.xyz/facet/components/avatar.md) | image with a text fallback, and the circle is the wrapper's |
| [`checkbox`](https://docs.astra-void.xyz/facet/components/checkbox.md) | checked, indeterminate, disabled |
| [`progress`](https://docs.astra-void.xyz/facet/components/progress.md) | determinate or indeterminate bar |
| [`radio-group`](https://docs.astra-void.xyz/facet/components/radio-group.md) | exactly one item checked |
| [`scroll-area`](https://docs.astra-void.xyz/facet/components/scroll-area.md) | viewport with a drawn overlay scrollbar |
| [`slider`](https://docs.astra-void.xyz/facet/components/slider.md) | track, range fill, draggable thumb |
| [`switch`](https://docs.astra-void.xyz/facet/components/switch.md) | toggle with an animated thumb |
| [`tabs`](https://docs.astra-void.xyz/facet/components/tabs.md) | list, triggers, switched panels |
| [`text-field`](https://docs.astra-void.xyz/facet/components/text-field.md) | single-line input with label, description, message |
| [`textarea`](https://docs.astra-void.xyz/facet/components/textarea.md) | multi-line input that grows with its text |
| [`toggle-group`](https://docs.astra-void.xyz/facet/components/toggle-group.md) | two-state buttons, single or multiple pressed |
Plus [`button`](https://docs.astra-void.xyz/facet/components/button.md), which is its own case — a recipe, a label recipe, and
`asChild` — and the **layered tier**, which starts here:
| Item | |
| --- | --- |
| [`dialog`](https://docs.astra-void.xyz/facet/components/dialog.md) | modal with overlay, header, footer, close — and a provider your app has to have |
`utils` (`cn`, and `ClassValue` re-exported as `ClassName`) and `text` (`TextSlot`) are the two
`registry:lib` items everything else imports.
> **Five of the twenty-one have been opened in Studio**
>
> `button`, `badge`, `card`, `label` and `separator` have been rendered and looked at. **Everything
> else has not** — it compiles through `rbxtsc`, it renders in the playground and in the previews on
> these pages, and that is a weaker claim.
>
> The distinction is worth keeping because it has already cost something: compiling is a static
> result, and Roblox runtime behavior does not show up in it. Reading the emitted Luau was not enough
> either — `card`'s description had no `font-*` in it, so it kept Roblox's LegacyArial default and was
> the only thing on screen in another typeface. Nobody caught that from the source.
**The registry is versioned.** Every push republishes the moving `r/`, and also writes an immutable
`r//` that never changes again. A project pins one through the `registry` field `facet.json`
already had — see [pinning a revision](https://docs.astra-void.xyz/facet/reference/facet-json.md#pinning-a-revision).
**`facet add` edits your client entry, once.** `dialog` declares a `PortalProvider`, and a missing
one is the only failure on this page that happens at *runtime* rather than at build time. So the CLI
parses the entry, asks, and writes it — see [wiring a provider](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider).
## What the one-primitive tier established
Three rules came out of building twelve components on twelve primitives, and every component after
them inherits all three. They are written up in
[Component conventions](https://docs.astra-void.xyz/facet/guides/component-conventions.md#wrapping-a-lattice-primitive).
1. **State a component styles by is mirrored, not reached for.** Lattice keeps its contexts private,
so a wrapper holds the value itself with `useControllableState` — the same hook the primitive
uses — and drives the primitive controlled.
2. **A `className` on a primitive call site has to be visible to the transformer.** Vela rewrites the
call sites it can *see*; a `className` tucked into a shared spread reaches the primitive as a raw
prop and is dropped in silence.
3. **Where the primitive owns a property, the recipe stays off it.** The slider's track carries no
`flex-*`, the switch's thumb no position, the textarea's input no height.
## What is not covered
> **A preview is not Studio**
>
> Every component page carries a live preview, and it is the real thing as far as it goes: the actual
> registry source, lowered by the actual Vela compiler against the actual `@facet-ui/theme` tokens,
> rendered by [Loom](https://docs.astra-void.xyz/loom/index.md). What it is *not* is Roblox. Loom reimplements Roblox's layout and text
> measurement; it does not run the engine, and its fidelity is
> [deliberately partial](https://docs.astra-void.xyz/loom/getting-started/scope-and-status.md).
>
> So a preview is strong evidence and weak proof. Where these pages state what something looks like on
> screen, it is because someone opened Studio and looked — that is still the only claim worth making
> about runtime behavior, and it is a different and stronger claim than "the preview looks right".
- **The geometry of the one-primitive tier is unverified.** All twelve compile and render, but what
the type system cannot see is thumb travel, range fill, scrollbar placement and textarea growth.
Those are the four things Studio still has to answer for that tier.
- **`dialog`'s geometry is unverified too**, and its three questions are separate: whether the panel
lands centred, whether the dim covers the screen beneath it, and whether the close ✕ reaches the
panel's right edge. The last one the preview *cannot* show — Loom does not lay out what `self-end`
lowers to. See [the corner ✕](https://docs.astra-void.xyz/facet/components/dialog.md#the-corner--is-not-a-corner-).
- **No `rbxtsc` check in the CLI's own test suite.** `test/e2e.test.ts` runs `init`, `add` and
`doctor` against a temporary registry, offline, with packages faked into `node_modules` at chosen
versions — which is the only way to assert on a project sitting below a floor. What it does not do
is compile the result. The playground app build is what checks that, and CI runs both on every
branch and pull request.
- **No image icons.** Facet renders `▾`, `✓`, `✕` as text glyphs and exposes the slot, so a project
that wants real artwork passes its own. That is a
[settled position](https://docs.astra-void.xyz/facet/guides/component-conventions.md#7-icons-are-text-glyphs-replaceable-by-slot),
not a gap.
- **No blocks.** `login-form`, `settings-panel`, `inventory-grid`, `shop-row` are on the roadmap,
after the singles settle.
- **Pinning is per project, not per component.** A revision says "this project builds against that
registry", not "`button` came from that revision and `card` from this one", so `facet diff` still
cannot attribute a change. See
[Updating copied components](https://docs.astra-void.xyz/facet/guides/updating-copied-components.md#pinning-a-registry-revision).
## What is coming, in order
The build order is by what each component needs underneath it, because that is how the conventions
get proven before anything complicated depends on them.
**Nothing beneath them** — pure recipe plus a host element. **Done.**
`aspect-ratio` came off this list rather than getting built: Vela lowers `aspect-*` onto
`UIAspectRatioConstraint`, so it is
[a class rather than a component](https://docs.astra-void.xyz/facet/guides/component-conventions.md#5-layout-is-an-instance-not-a-property).
**One Lattice primitive** — **done as of 0.4.0**, pending Studio verification.
`toggle` came off this list without being built: `@lattice-ui/react-toggle` does not exist, and a
standalone pressed state is exactly the controlled/uncontrolled logic that belongs in Lattice rather
than in a copied file. It returns when the primitive does — or a
[one-item `toggle-group`](https://docs.astra-void.xyz/facet/components/toggle-group.md#toggle-is-not-in-the-registry) covers it.
**Layered** — needs portals, focus trapping, or popper. [`dialog`](https://docs.astra-void.xyz/facet/components/dialog.md) is
built; the rest is `alert-dialog` · `popover` · `tooltip` · `dropdown-menu` · `context-menu` ·
`select` · `combobox` · `toast` · `sheet` · `command`.
Each of them declares the same `PortalProvider` `dialog` does, so the entry edit happens once.
`dropdown-menu` will wrap `@lattice-ui/react-menu` — there is no `react-dropdown-menu` — and
`alert-dialog` and `sheet` are both `react-dialog` again with different chrome.
**Blocks** — multi-file compositions, once the singles settle: `login-form` · `settings-panel` ·
`inventory-grid` · `shop-row`.
**Roblox-native, with no shadcn counterpart**, worth their own pass rather than being wedged into
that list: `viewport` (a `ViewportFrame` with a model), `billboard`, `surface`, `player-list`,
`hotbar`.
## Decisions that are settled
Each of these is written down in the repo with the reasoning kept where it can be argued with.
| Decision | Short version |
| --- | --- |
| **Text is a prop** | `Text?: string` on every component that draws a string; `children` stays composition. The compiler leaves no other option. [→](https://docs.astra-void.xyz/facet/guides/text-and-labels.md) |
| **`cn` does not merge conflicts** | Vela's last-token-wins is what `tailwind-merge` exists to fake, so ordering discipline replaces a merge pass. [→](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#the-one-rule) |
| **One registry style** | The `style` field stays in `facet.json`, pinned to `"default"`. A second style does not arrive. |
| **Nothing is recorded at copy time** | No `facet.lock`, no hashes. A hash answers *whether* a file changed; a diff has to show *how*. [→](https://docs.astra-void.xyz/facet/guides/updating-copied-components.md#why-there-is-no-lock-file) |
| **A revision is a commit** | Every push publishes an immutable `r//` beside the moving `r/`, and pinning is the `registry` field that already existed — so no CLI change, no format change, and a CLI released months ago can pin today. [→](https://docs.astra-void.xyz/facet/reference/registry-format.md#revisions) |
| **Icons are text glyphs** | Replaceable by slot. This was listed as blocking the layered components; it does not. [→](https://docs.astra-void.xyz/facet/guides/component-conventions.md#7-icons-are-text-glyphs-replaceable-by-slot) |
| **Ratio is a class, not a component** | Vela lowers `aspect-*` onto the native constraint, so a wrapper would add an instance to carry what an existing instance carries. [→](https://docs.astra-void.xyz/facet/guides/component-conventions.md#5-layout-is-an-instance-not-a-property) |
| **One recipe object per file** | Luau allows 200 module-scope locals and Vela inlines its runtime per file, so every export costs a register. `card` stopped loading over exactly this. [→](https://docs.astra-void.xyz/facet/guides/component-conventions.md#6-flat-named-exports) |
| **The scrim is black** | The one class in the registry that names a colour instead of a role. No role is dark in both modes, and a token is too large a commitment for one class in one component. [→](https://docs.astra-void.xyz/facet/components/dialog.md#the-scrim) |
| **A component declares its own providers** | `RegistryItem.providers`, so the next layered component is wired correctly by a CLI released before it existed. [→](https://docs.astra-void.xyz/facet/reference/registry-format.md#providers) |
| **npm OIDC trusted publishing** | No `NPM_TOKEN` in repository settings. Publishes from GitHub Actions with provenance attestations. |
## Decisions that are open
- **Runtime theming.** Vela resolves classes at compile time, so a build carries one mode. A
settings menu with a light/dark toggle has no answer today. The leaning is: do nothing now, and
eventually get a Vela-side token indirection — which Facet cannot make unilaterally. The tempting
middle option, a runtime `ThemeProvider` alongside classes, is probably the trap: it buys runtime
theming at the cost of the property that makes copy-in work, which is that a component's
appearance is entirely described by its classes.
- **Whether `facet diff` should fetch the text a component was copied from.** Revisions make it
*possible* — the base text is now addressable rather than something you would have to store — but
it needs a record of which revision each component came from, which reopens
[provenance](https://docs.astra-void.xyz/facet/guides/updating-copied-components.md#why-there-is-no-lock-file). Nothing is
built.
- **`facet create`** — scaffolding a new roblox-ts project preconfigured for Facet, as Lattice's CLI
does.
- **What to do about the inert `className` prop.** Every component advertises one, and a class
passed to it from a Vela-compiled call site is dropped in silence —
[why](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site). The options are to stop
advertising it, to keep it as the internal composition slot it actually is, or to push for a Vela
change that hands a component its `className` as a string instead of pre-resolving it. Nothing is
decided.
- **Whether the registry should make `asChild`'s missing label easier.** `asChild` itself is no
longer in question — it works as of Lattice 0.8.0, verified in Studio against a bare ``
child, with the recipe's background, size, hover variant and re-parented `UICorner`/`UIListLayout`/
`UIPadding` all crossing `Slot` intact. What does *not* cross is the label: `TextSlot` never
renders on that path, so the child draws its own text at Roblox's 8px near-black default.
`buttonLabelVariants` is exported so a consumer can state it themselves, but whether that pairing
should be documented, automated, or something else is unresolved. See
[Button](https://docs.astra-void.xyz/facet/components/button.md#aschild).
---
# Button
> Six variants, four sizes, two recipes — and the one thing asChild does not carry across.
Source: https://docs.astra-void.xyz/facet/components/button/
```bash
npx facet-rbxts add button
```
Copies `ui/button.tsx`, plus `lib/utils.ts` and `lib/text.tsx` as registry dependencies. Needs
`@facet-ui/react-variants` and `@lattice-ui/react-runtime@^0.8.0`.
```tsx
import { Button } from "../shared/ui/button";
print("saved")} />
```
_Interactive preview: Every variant and size, plus the disabled fade — rendered from the registry source._
Renders a `TextButton`. Unknown props forward onto it and are type-checked against it, so a prop `TextButton` does not accept is a compile error. The primitive owns `Active` and `Selectable` from the disabled state, so values you pass for those are ignored.
`Text`, `AutoButtonColor`, `BackgroundTransparency` and `BorderSizePixel` are set as *neutral
defaults* before the passthrough spread, so you can override those — see
[Neutral defaults](#neutral-defaults). `Event` is composed rather than replaced, so a handler you
pass still fires alongside the component's own.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `Text` | `string` | The label. Drawn as a styled child textlabel, not as this instance's Text — so it can be sized and coloured independently and sit beside an icon. |
| `variant` | `"default" \| "destructive" \| "outline" \| "secondary" \| "ghost" \| "link"` | Surface and label colour. Defaults to default. |
| `size` | `"sm" \| "md" \| "lg" \| "icon"` | Height, padding, and label size. Defaults to md. |
| `disabled` | `boolean` | Dims to opacity-50, clears Active and Selectable, and swallows onClick. Not a Vela variant — Facet's own state. |
| `onClick` | `() => void` | Composed onto Activated rather than replacing it, so a passthrough Event handler still fires. |
| `asChild` | `boolean` | Render the single child element instead of a textbutton, merging the recipe and behavior onto it. Errors if there is no child element. |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
| `children` | `React.ReactNode` | Composition — an icon, a nested element. Rendered instead of the label when Text is absent. |
Everything else is forwarded onto the `TextButton` and type-checked against it.
## Variants
| `variant` | Surface | Label |
| --- | --- | --- |
| `default` | `bg-primary`, `hover:bg-primary/90` | `text-primary-foreground` |
| `destructive` | `bg-destructive`, `hover:bg-destructive/90` | `text-destructive-foreground` |
| `outline` | `border border-input bg-background`, `hover:bg-accent` | `text-foreground` |
| `secondary` | `bg-secondary`, `hover:bg-secondary/80` | `text-secondary-foreground` |
| `ghost` | none, `hover:bg-accent` | `text-foreground` |
| `link` | none | `text-primary` |
| `size` | Geometry | Label size |
| --- | --- | --- |
| `sm` | `h-8 px-3` | `text-sm` |
| `md` | `h-9 px-4` | `text-sm` |
| `lg` | `h-10 px-6` | `text-base` |
| `icon` | `h-9 w-9` | `text-sm` |
## Two recipes, and why
```tsx
export const buttonVariants = fv(
"flex-row items-center justify-center gap-2 w-fit rounded-md transition duration-150",
{ variants: { variant: { … }, size: { … } }, defaultVariants: { variant: "default", size: "md" } },
);
export const buttonLabelVariants = fv("font-medium", {
variants: { variant: { … }, size: { … } },
defaultVariants: { variant: "default", size: "md" },
});
```
Nothing inherits on Roblox — `text-sm` on the button does not reach the label inside it, because
text properties belong to the instance that draws the text. So the label needs its own recipe,
keyed off the same two props. Both are exported, which matters for `asChild` below.
`w-fit` in the base is load-bearing: padding does not grow a frame on Roblox, so without an
automatic width this renders **zero pixels wide**. The `icon` size overrides it with a concrete
`w-9`, and the later token wins.
## Disabled
Vela has no `disabled:` variant — disabled is Facet's state, not the host's — so the dimming is
applied rather than selected:
```tsx
const className = buttonVariants({
variant: props.variant,
size: props.size,
className: cn(disabled && "opacity-50", props.className),
});
```
Note where it goes: **inside the recipe's `className` slot, ahead of the consumer's**. Resolution is
last-token-wins, so anything appended after `props.className` is an override the consumer cannot
undo. `button` had this backwards until it was written down as a rule —
[Variants and classes](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#the-one-rule).
> **The label states its own fade, as a prop**
>
> `opacity-*` composes into everything the compiler can see underneath an element — but a *component*
> child is exactly what it cannot see, because the instances that component renders are created
> somewhere else. Left alone, a disabled button sat at `BackgroundTransparency` 0.5 with its label at
> `TextTransparency` 0: half a faded button.
>
> Putting `opacity-50` on the label's own recipe does not fix it either. The class resolves against
> `__velaTag = TextSlot`, and the runtime cannot know which instance a component will render, so it
> drops the text-only half and keeps a background that was already invisible. The emitted Luau carried
> the token; the label still measured 0.
>
> So this is the one place a class genuinely cannot express the intent in either direction, and
> `TextTransparency={disabled ? 0.5 : 0}` says it instead. Both halves were measured in Studio rather
> than assumed.
The label fades rather than merely recolouring to `text-muted-foreground`, because that is what
`opacity` does on the web: CSS fades an element and its text together, so a shadcn
`disabled:opacity-50` button dims its label too. Recolouring would be the more legible option, and
it would make `disabled` mean two different things depending on which component you are looking at.
Parity won; legibility is the price.
## `asChild`
Renders the single child element instead of a `textbutton`, merging the recipe and the behavior
props onto it through Lattice's `Slot`.
```tsx
```
Verified in Studio against a bare ``, which carries no styling of its own: the cloned
instance came out with `BackgroundColor3` 0.153/0.153/0.165 (`bg-secondary`), `Size` `{0,0},{0,32}`
and `AutomaticSize.X` (`h-8 w-fit`), `bg-secondary/80` on hover, and `UIListLayout`, `UICorner` and
`UIPadding` re-parented underneath it. **The recipe crosses `Slot` whole.**
> **What asChild does not carry is the label**
>
> `TextSlot` never renders on this path — the child draws its own text — so `buttonLabelVariants` is
> not applied and the text falls back to Roblox's 8px near-black default. On a dark surface that is
> invisible.
>
> This is "nothing inherits" once more. A consumer reaching for `asChild` states the text styling on
> their own element, and `buttonLabelVariants` is exported for exactly that:
>
> ```tsx
>
>
>
> ```
>
> Whether the registry should make this easier is [still open](https://docs.astra-void.xyz/facet/getting-started/scope-and-status.md#decisions-that-are-open).
`asChild` needs `@lattice-ui/react-runtime@^0.8.0`, and that floor is not about the feature existing.
It was broken for a reason unrelated to `className`: Lattice keyed its UI modifier table by the
lowercase JSX tag, while roblox-ts labels a host element with its Roblox class name, so ` `
arrived as `"UICorner"`, missed the lookup, and counted as a second slot target. Every Facet recipe
emits at least a `UIListLayout` or a `UICorner`, so **no component could use `asChild` at all** until
0.8.0 fixed it upstream.
## Neutral defaults
A bare `` renders an opaque grey box labelled "Button". That is a look, and it has to be
cleared before styling means anything:
```tsx
const NEUTRAL_PROPS = {
AutoButtonColor: false,
BackgroundTransparency: 1,
BorderSizePixel: 0,
Text: "",
};
```
Spread order is **neutral defaults → consumer passthrough → behavior props**. Consumers can override
appearance; they can never override behavior. `Text` is cleared because the label is a child
instance, not this instance's property.
---
# Checkbox
> The first component with a Lattice primitive underneath it, and the first that has to hold a copy of the state it styles by.
Source: https://docs.astra-void.xyz/facet/components/checkbox/
```bash
npx facet-rbxts add checkbox
```
Copies `ui/checkbox.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-checkbox@^0.8.0`.
```tsx
import { Checkbox } from "../shared/ui/checkbox";
print(checked)} />
```
_Interactive preview: Unchecked, checked, indeterminate, disabled. The mark is a text glyph, not an image._
Renders a `TextButton`. Unknown props forward onto it and are type-checked against it, so a prop `TextButton` does not accept is a compile error.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `checked` | `boolean \| "indeterminate"` | Controlled value. Pass it with onCheckedChange to drive the box from your own state. |
| `defaultChecked` | `boolean \| "indeterminate"` | Uncontrolled starting value. Defaults to false. |
| `onCheckedChange` | `(checked: boolean \| "indeterminate") => void` | Fires on every change, controlled or not. |
| `disabled` | `boolean` | Blocks the press and adds opacity-50 to the recipe's className slot. |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
There is no `Text` and no `children`: the box draws a glyph and nothing else. A label beside it is a
separate [`Label`](https://docs.astra-void.xyz/facet/components/label.md) in a `flex-row` frame, the same pairing shadcn writes.
## The state is mirrored, not reached for
This is the first thing every component in this tier had to solve, and the reason is one line of
Lattice's design:
```tsx
const [checked, setChecked] = useControllableState({
value: props.checked,
defaultValue: props.defaultChecked ?? false,
onChange: props.onCheckedChange,
});
```
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 this file's job — `border-input` when
clear, `border-primary bg-primary` when not — so the wrapper needs the same answer.
The way out is not to reach into the primitive. It is to hold the value here with
`useControllableState` — *the same hook the primitive uses* — and then drive the primitive
**controlled** from it. One copy of the state, and it lives in the file you own.
> **This is the shape, not a special case**
>
> `switch`, `tabs`, `toggle-group`, `accordion` and `radio-group` all do exactly this. Where a
> component styles by a state, the state is mirrored. Where it does not — `progress` maps a number to
> a width, `radio-group`'s inner dot is mounted and unmounted by the primitive — there is no mirror,
> because nothing here needed to know.
## State classes go inside the slot
```tsx
const className = checkboxVariants.root({
className: cn(
checked !== false && "border-primary bg-primary",
disabled && "opacity-50",
props.className,
),
});
```
Note the order: the state classes come **before** `props.className`, inside the recipe's slot.
Resolution is last-token-wins and `cn` does not merge conflicts, so anything appended *after* the
consumer's class would be an override the consumer cannot undo. That is the
[one rule](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#the-one-rule) the whole registry follows.
`checked !== false` rather than `checked === true`: indeterminate is checked-enough to paint.
## Three parts, one recipe object
```tsx
export const checkboxVariants = {
root: fv("size-4 rounded-sm border border-input transition duration-150"),
indicator: fv("size-full flex-row items-center justify-center"),
glyph: fv("size-fit text-xs font-bold text-primary-foreground text-center"),
};
```
`font-bold` on the glyph is load-bearing, and not for weight. Vela leaves `FontFace` alone when no
`font-*` token appears, and Roblox's untouched default is LegacyArial — which is
[the bug that shipped in `card`](https://docs.astra-void.xyz/facet/components/card.md#wrapping-and-alignment-are-classes). The
glyph is its own `textlabel` and nothing inherits, so it states its own typeface like every other
text instance in the registry.
`size-fit` on a glyph inside a `size-full` indicator is what centres it: the indicator does the
`items-center justify-center`, and the glyph is only as big as the character.
## The mark is a text glyph
```tsx
```
Roblox has no icon font, so `✓` and `–` are characters. That is a
[settled position](https://docs.astra-void.xyz/facet/guides/component-conventions.md#7-icons-are-text-glyphs-replaceable-by-slot)
rather than a shortcut — shipping images means owning the upload, the moderation and the licensing
forever. The file is yours: swap the `textlabel` for an `imagelabel` with your own asset and nothing
else in the component changes.
## Indeterminate is a value, not a flag
`CheckedState` is `boolean | "indeterminate"`, so the third state travels through the same prop as
the other two. A parent checkbox over a list of children is the case it exists for:
```tsx
setAll(next === true)}
/>
```
---
# Label
> A form label — one recipe, no variants, and the smallest component in the registry.
Source: https://docs.astra-void.xyz/facet/components/label/
```bash
npx facet-rbxts add label
```
Copies `ui/label.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants` and
`@lattice-ui/react-runtime`.
```tsx
import { Label } from "../shared/ui/label";
```
A second text colour is a second element, not a `className` on this one — a class written here is
lowered before `Label` ever sees it, and then overwritten by the component's own recipe. See
[Overriding from the call site](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
_Interactive preview: The recipe, and a second text colour stated on its own element._
Renders a `TextLabel`. Unknown props forward onto it and are type-checked against it, so a prop `TextLabel` does not accept is a compile error.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `Text` | `string` | The label. Drawn as this instance's own Text — unlike Button, there is nothing to compose around, so there is no TextSlot. |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
Everything else is forwarded onto the `TextLabel`.
## The recipe
```tsx
export const labelVariants = fv("text-foreground text-sm font-medium");
```
That is the whole thing — no variant axes. `fv()` with no config is still worth using over a bare
string: it gives you the `className` slot with the right ordering, and it is the seam a variant axis
grows into if you add one.
`font-medium` is not optional. Vela leaves `FontFace` untouched when no `font-*` token appears, and
Roblox's untouched default is LegacyArial — a different typeface at a visibly different size from
the SourceSansPro everything else resolves to. Every text recipe in the registry declares a weight
for that reason. See [Text and labels](https://docs.astra-void.xyz/facet/guides/text-and-labels.md#every-text-recipe-declares-a-font-).
## A leaf, so it draws its own text
`Button` and `Badge` delegate to `TextSlot` because they compose — a label beside an icon, a child
element instead of a string. `Label` has nothing to compose around, so it draws `Text` directly:
```tsx
```
`AutomaticSize` is set as an instance prop rather than through a class, and it sits *before* the
passthrough spread so a consumer can override it. Without a resolved size, a label collapses the
automatic sizing of whatever contains it — see
[Component conventions](https://docs.astra-void.xyz/facet/guides/component-conventions.md#2-automaticsize-is-a-chain).
---
# Radio group
> One value across a set of dials — and the one component in this tier that mirrors half the state and lets the primitive keep the rest.
Source: https://docs.astra-void.xyz/facet/components/radio-group/
```bash
npx facet-rbxts add radio-group
```
Copies `ui/radio-group.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-radio-group@^0.8.0`.
```tsx
import { RadioGroup, RadioGroupItem } from "../shared/ui/radio-group";
import { Label } from "../shared/ui/label";
```
_Interactive preview: Three items, one checked. The row around each dial is yours — Facet ships no RadioGroupLabel._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `RadioGroup` | `Frame` | `flex-col gap-2 w-fit h-fit` |
| `RadioGroupItem` | `TextButton` | `size-4 rounded-full border border-input transition duration-150` |
Two parts, not four. The indicator and the dot inside it are drawn by `RadioGroupItem` — they take
no props, so exporting them would cost two Luau registers and buy nothing. See
[the register limit](https://docs.astra-void.xyz/facet/guides/component-conventions.md#6-flat-named-exports).
## Props
### `RadioGroup`
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Controlled value. Pass it with onValueChange. |
| `defaultValue` | `string` | Uncontrolled starting value. |
| `onValueChange` | `(value: string) => void` | Fires when a different item is checked. |
| `disabled` | `boolean` | Disables every item in the group. An item can also disable itself. |
| `orientation` | `"horizontal" \| "vertical"` | Goes to the primitive for keyboard and gamepad navigation, and adds flex-row to the frame when horizontal. Defaults to vertical. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
### `RadioGroupItem`
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Required. What this item sets the group to. |
| `disabled` | `boolean` | Disables this item alone. |
| `className` | `ClassName` | Threaded into the item recipe's className slot inside the component. |
There is no `Text` on either part. A radio button is a dial; the words next to it are a
[`Label`](https://docs.astra-void.xyz/facet/components/label.md) you place yourself, exactly as shadcn does.
## Half the state is mirrored, half is not
The [mirrored-state rule](https://docs.astra-void.xyz/facet/components/checkbox.md#the-state-is-mirrored-not-reached-for) applies
to what this file *styles by*, and here that is only the border:
```tsx
const checked = group.value === props.value;
**A horizontal group is not a row of labelled rows**
>
> The `flex-row` lands on the group's own frame, so its children are laid out in a row. If each child
> is a labelled row of its own — a `flex-row` frame holding a dial and a `Label` — you get a row of
> rows, which is usually what you want. Nesting is yours to arrange; the component only flips its own
> axis.
---
# Slider
> Three instances whose geometry is entirely the primitive's — and the one component whose track deliberately has no layout.
Source: https://docs.astra-void.xyz/facet/components/slider/
```bash
npx facet-rbxts add slider
```
Copies `ui/slider.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-slider@^0.8.0`.
```tsx
import { Slider } from "../shared/ui/slider";
saveVolume(value)} />
```
_Interactive preview: A plain slider, one stepped by 10, and a disabled one. The thumb overhangs the track because it is size-4 on an h-2 bar._
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `number` | Controlled value. Pass it with onValueChange. |
| `defaultValue` | `number` | Uncontrolled starting value. |
| `onValueChange` | `(value: number) => void` | Fires continuously as the thumb moves. |
| `onValueCommit` | `(value: number) => void` | Fires once per gesture, when the drag or the keypress lets go. This is the one to save from. |
| `min` | `number` | Lower bound. |
| `max` | `number` | Upper bound. |
| `step` | `number` | Quantises the value. The thumb still moves smoothly; the value does not. |
| `orientation` | `"horizontal" \| "vertical"` | Which axis the thumb travels along. |
| `disabled` | `boolean` | Blocks the drag, and fades the track and the thumb with opacity-50. |
| `className` | `ClassName` | Threaded into the track recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
This is the one component in the registry with **no passthrough bag**. There is no
`PassthroughProps ` on `SliderProps` and no `getPassthroughProps` in the body: three separate
instances render here and there is no single one an unknown prop should land on.
## The track has no `flex-*`, and that is the point
```tsx
export const sliderVariants = {
track: fv("h-2 w-full rounded-full bg-secondary"),
range: fv("rounded-full bg-primary"),
thumb: fv("size-4 rounded-full border border-primary bg-background"),
};
```
Every other container in the registry opens with `flex-row` or `flex-col`. This one does not, and
removing that class is the component's whole design decision.
A `flex-*` class lowers to a `UIListLayout`, and a `UIListLayout` **positions every child it has**.
The range's fill and the thumb's travel are both `Position`/`Size` on instances inside the track,
written by Lattice as the value moves. Add a layout and the list overwrites them on the next frame.
> **The thumb rides the track, not a slot inside it**
>
> `size-4` on an `h-2` bar means the thumb is twice the track's height and hangs over both edges —
> which is what a slider knob looks like, and only possible because nothing is laying it out. The
> primitive centres its anchor; the overhang follows.
>
> What Studio still has to confirm is that the overhang lands symmetrically and the range fill stops
> exactly under the thumb. Loom renders it, but the
> [preview is not Studio](https://docs.astra-void.xyz/facet/getting-started/scope-and-status.md#what-is-not-covered).
## `onValueChange` versus `onValueCommit`
`onValueChange` fires on every frame of a drag. `onValueCommit` fires once, when the gesture ends.
For a volume slider you want both: the first to hear the change live, the second to write it to a
`DataStore`. Wiring a save to `onValueChange` means one request per frame of a drag.
## Disabled fades in two places
```tsx
```
The track and the thumb each state the fade, because
[`opacity-*` does not cross a component boundary](https://docs.astra-void.xyz/facet/guides/component-conventions.md#3-nothing-inherits) —
the thumb is a primitive child, not JSX the compiler can see under the track. The registry's rule is
the same one it always is: nothing inherits, so every instance states its own appearance.
---
# Switch
> A track and a thumb, where the thumb's travel is the primitive's job and this file only says what it looks like.
Source: https://docs.astra-void.xyz/facet/components/switch/
```bash
npx facet-rbxts add switch
```
Copies `ui/switch.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-switch@^0.8.0`.
```tsx
import { Switch } from "../shared/ui/switch";
setMuted(!checked)} />
```
_Interactive preview: Off, on, and disabled. The thumb's position is Lattice's; the track's colour is this file's._
Renders a `TextButton`. Unknown props forward onto it and are type-checked against it, so a prop `TextButton` does not accept is a compile error. The primitive owns `AnchorPoint` and `Position` on the thumb, which it animates between the track's edges, so values you pass for those are ignored.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `checked` | `boolean` | Controlled value. Pass it with onCheckedChange. |
| `defaultChecked` | `boolean` | Uncontrolled starting value. Defaults to false. |
| `onCheckedChange` | `(checked: boolean) => void` | Fires on every change, controlled or not. |
| `disabled` | `boolean` | Blocks the press and adds opacity-50 to the recipe's className slot. |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
No `indeterminate` here, unlike [Checkbox](https://docs.astra-void.xyz/facet/components/checkbox.md) — a switch is on or off, and
Lattice types it `boolean`.
## Two classes, and one of them is the whole component
```tsx
export const switchVariants = {
root: fv("h-5 w-9 rounded-full transition duration-150"),
thumb: fv("size-4 rounded-full bg-background"),
};
```
The root carries no background colour at all. It is applied from the mirrored state instead:
```tsx
const className = switchVariants.root({
className: cn(checked ? "bg-primary" : "bg-input", disabled && "opacity-50", props.className),
});
```
That is the same [mirrored-state](https://docs.astra-void.xyz/facet/components/checkbox.md#the-state-is-mirrored-not-reached-for)
shape `checkbox` establishes — `useControllableState` here, the primitive driven controlled from it —
because Lattice keeps its context private and the track's colour changes with the value.
## The thumb has no position
> **Nothing in this file says where the thumb goes**
>
> `Switch.Thumb` owns `AnchorPoint` and `Position`, and animates them between the track's edges for
> whatever size the thumb turns out to be. The recipe says `size-4 rounded-full bg-background` and
> stops.
>
> Write a `left-*` or a `translate-x-*` here and you are fighting the primitive on a property it
> rewrites every frame. If you want a different travel, change the thumb's *size* or the track's — the
> geometry follows from those.
This is [layout is an instance, not a property](https://docs.astra-void.xyz/facet/guides/component-conventions.md#5-layout-is-an-instance-not-a-property)
seen from the other side: a Lattice primitive that owns a property is as load-bearing as a
`UIListLayout` that owns one, and the recipe stays out of both.
## Sizing it
`h-5 w-9` on the track and `size-4` on the thumb are the only numbers in the component, and they are
paired — a 16px thumb inside a 20px track leaves 2px of inset on each edge. Changing one means
changing the other; there is no variant that does it for you, because the file is short enough to
edit.
A `className` at the call site cannot do it either. `h-5 w-9` is in the recipe, and the recipe
resolves *after* the props a call-site class arrives as — see
[overriding from the call site](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
---
# Text field
> Five parts around a TextBox — and the component where a state has to be stated twice because nothing inherits.
Source: https://docs.astra-void.xyz/facet/components/text-field/
```bash
npx facet-rbxts add text-field
```
Copies `ui/text-field.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-text-field@^0.8.0`.
```tsx
import {
TextField,
TextFieldDescription,
TextFieldInput,
TextFieldLabel,
TextFieldMessage,
} from "../shared/ui/text-field";
```
_Interactive preview: A valid field and an invalid one. Click into either — the box is a real TextBox._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `TextField` | `Frame` | `flex-col gap-2 w-full h-fit` |
| `TextFieldInput` | `TextBox` | `h-9 w-full rounded-md border border-input px-3 text-left text-sm font-normal text-foreground placeholder-muted-foreground focus:border-ring` |
| `TextFieldLabel` | `TextButton` | `w-full h-fit text-left text-sm font-medium text-foreground` |
| `TextFieldDescription` | `TextLabel` | `w-full h-fit whitespace-normal text-left text-xs font-normal text-muted-foreground` |
| `TextFieldMessage` | `TextLabel` | `w-full h-fit whitespace-normal text-left text-xs font-medium text-destructive` |
The label renders a `TextButton` rather than a `TextLabel` because Lattice makes it focus the input
when pressed — the Roblox equivalent of ``.
Renders a `TextBox`. Unknown props forward onto it and are type-checked against it, so a prop `TextBox` does not accept is a compile error.
That is `TextFieldInput`. `TextField` forwards onto a `Frame`, the two text parts onto a `TextLabel`,
and the label onto a `TextButton`.
## Props
### `TextField`
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Controlled value. Pass it with onValueChange. |
| `defaultValue` | `string` | Uncontrolled starting text. |
| `onValueChange` | `(value: string) => void` | Fires on every keystroke. |
| `onValueCommit` | `(value: string) => void` | Fires when the box loses focus, with the text as it stands. |
| `disabled` | `boolean` | Blocks focus and typing. |
| `readOnly` | `boolean` | Focusable, not editable. |
| `invalid` | `boolean` | Carries the state into Lattice's context. It does not colour anything on its own — see below. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
### `TextFieldInput`
| Prop | Type | Description |
| --- | --- | --- |
| `invalid` | `boolean` | Adds border-destructive. This is the one that draws the red border. |
| `disabled` | `boolean` | Adds opacity-50. |
| `className` | `ClassName` | Threaded into the input recipe's className slot inside the component. |
`TextFieldLabel`, `TextFieldDescription` and `TextFieldMessage` each take `Text` and `className`.
## `invalid` is stated twice, and that is not a bug
```tsx
```
The root's `invalid` is what reaches Lattice's context — the behavior half. The input's `invalid` is
what turns the border `border-destructive` — the appearance half.
They are separate because **nothing inherits**. A `TextBox`'s stroke colour lives on that `TextBox`;
there is no descendant selector and no cascade to carry the root's state down to it. It is the same
repetition [`Alert`](https://docs.astra-void.xyz/facet/components/alert.md#variant-goes-on-every-part) has with `variant`, reached
from a different direction.
> **Setting only one of them is silent**
>
> `` with a plain ` ` compiles and renders a normally-bordered box
> that Lattice considers invalid. Nothing warns — the parts have no relationship for anything to check.
>
> If the repetition bothers you in your project, the file is yours: read the primitive's context in
> `TextFieldInput`, or collapse the five parts into one component that takes three strings.
## `font-normal` on the input is load-bearing
A `TextBox` draws its own text, so unlike [Button](https://docs.astra-void.xyz/facet/components/button.md) there is no second
label recipe here — the text classes sit on the input itself. `font-normal` is in that list not for
weight but for existence: Vela leaves `FontFace` alone when no `font-*` token appears, and Roblox's
untouched default is LegacyArial. That is
[the bug that shipped in `card`](https://docs.astra-void.xyz/facet/components/card.md#wrapping-and-alignment-are-classes), and
every text-drawing instance in the registry states a `font-*` because of it.
`placeholder-muted-foreground` colours `PlaceholderColor3`; `focus:border-ring` swaps the stroke
while the box has focus.
---
# Textarea
> The one recipe in the registry that declares no height — because the primitive grows it line by line.
Source: https://docs.astra-void.xyz/facet/components/textarea/
```bash
npx facet-rbxts add textarea
```
Copies `ui/textarea.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-textarea@^0.8.0`.
```tsx
import {
Textarea,
TextareaDescription,
TextareaInput,
TextareaLabel,
} from "../shared/ui/textarea";
```
_Interactive preview: Three rows to start, five at most. Type into it and the box grows a line at a time._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `Textarea` | `Frame` | `flex-col gap-2 w-full h-fit` |
| `TextareaInput` | `TextBox` | `w-full rounded-md border border-input px-3 py-2 text-left text-sm font-normal text-foreground placeholder-muted-foreground focus:border-ring` |
| `TextareaLabel` | `TextButton` | `w-full h-fit text-left text-sm font-medium text-foreground` |
| `TextareaDescription` | `TextLabel` | `w-full h-fit whitespace-normal text-left text-xs font-normal text-muted-foreground` |
Four parts, not five — there is no `TextareaMessage`. A multi-line box that needs a validation line
can reach for [`TextFieldMessage`](https://docs.astra-void.xyz/facet/components/text-field.md), or the file is yours to add one.
Renders a `TextBox`. Unknown props forward onto it and are type-checked against it, so a prop `TextBox` does not accept is a compile error. The primitive owns `Size` on the Y axis, which it grows with the text, so values you pass for it are ignored.
## The input declares no height
```tsx
input: fv(
"w-full rounded-md border border-input px-3 py-2 …",
),
```
Rule 1 of the [conventions](https://docs.astra-void.xyz/facet/guides/component-conventions.md#1-declare-both-axes-always) is
declare both axes, always — a Roblox instance with an unresolved axis renders at zero and takes the
row above it with it. This recipe breaks that rule on purpose, and it is the only one that does.
`Textarea.Input` owns `Size.Y`. It measures the wrapped text and sets the height between `minRows`
and `maxRows` on every keystroke. A height class here would not lose an argument with the primitive —
it would be overwritten a frame later, which is worse, because the first render would look right.
> **This is the same rule as the slider's missing flex**
>
> Where a Lattice primitive owns a property, the recipe stays off it. [Slider](https://docs.astra-void.xyz/facet/components/slider.md#the-track-has-no-flex--and-that-is-the-point)
> omits `flex-*` from its track for exactly this reason, and [Switch](https://docs.astra-void.xyz/facet/components/switch.md#the-thumb-has-no-position)
> omits position from its thumb. A primitive that owns a property is as load-bearing as the
> `UIListLayout` that owns one.
## Props
### `Textarea`
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Controlled value. Pass it with onValueChange. |
| `defaultValue` | `string` | Uncontrolled starting text. |
| `onValueChange` | `(value: string) => void` | Fires on every keystroke. |
| `onValueCommit` | `(value: string) => void` | Fires when the box loses focus, with the text as it stands. |
| `autoResize` | `boolean` | Grow with the text. On by default; the box then sizes between minRows and maxRows. |
| `minRows` | `number` | Floor for the grown height. |
| `maxRows` | `number` | Ceiling. Past it the box scrolls instead. |
| `disabled` | `boolean` | Blocks focus and typing. |
| `readOnly` | `boolean` | Focusable, not editable. |
| `invalid` | `boolean` | Carries the state into Lattice's context. The red border comes from the input's own invalid. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
### `TextareaInput`
| Prop | Type | Description |
| --- | --- | --- |
| `invalid` | `boolean` | Adds border-destructive. |
| `disabled` | `boolean` | Adds opacity-50. |
| `className` | `ClassName` | Threaded into the input recipe's className slot inside the component. |
`invalid` is stated on the root **and** on the input, for
[the reason Text field spells out](https://docs.astra-void.xyz/facet/components/text-field.md#invalid-is-stated-twice-and-that-is-not-a-bug):
nothing inherits, so the context half and the appearance half are two different props on two
different instances.
## `autoResize` off
With `autoResize={false}` the primitive stops touching `Size.Y`, and the recipe's missing height
becomes the problem rule 1 warns about: give the input one.
This is the rare case where a call-site `className` is the right place to do it. A class written
there is resolved at that call site and arrives as instance properties, which the component's own
recipe then overwrites *for the properties it also names* — and this recipe names no height, so
` ` survives. Anything the recipe does state, like `w-full`, does
not. The mechanism, and why it reads backwards from `cn`'s own composition rules, is in
[overriding from the call site](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
---
# Toggle group
> A set of two-state buttons — and the component that had to write its `className` out twice to keep the compiler able to see it.
Source: https://docs.astra-void.xyz/facet/components/toggle-group/
```bash
npx facet-rbxts add toggle-group
```
Copies `ui/toggle-group.tsx`, plus `lib/utils.ts` and `lib/text.tsx`. Needs
`@facet-ui/react-variants`, `@lattice-ui/react-runtime@^0.8.0` and
`@lattice-ui/react-toggle-group@^0.8.0`.
```tsx
import { ToggleGroup, ToggleGroupItem } from "../shared/ui/toggle-group";
```
_Interactive preview: Single on top, multiple below. A pressed item takes bg-accent and its label takes text-accent-foreground._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `ToggleGroup` | `Frame` | `flex-row items-center gap-1 w-fit h-fit` |
| `ToggleGroupItem` | `TextButton` | `flex-row items-center justify-center h-9 w-fit px-3 rounded-md transition duration-150 hover:bg-muted` |
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
That is the root. `ToggleGroupItem` takes no passthrough bag — it renders a button and a label, and
there is no single instance an unknown prop should land on.
## Props
### `ToggleGroup`
| Prop | Type | Description |
| --- | --- | --- |
| `type` | `"single" \| "multiple"` | Required. single keeps at most one item pressed; multiple lets them accumulate. This is what decides whether value is a string or an array. |
| `value` | `string \| string[]` | Controlled value. A string under single, an array under multiple. |
| `defaultValue` | `string \| string[]` | Uncontrolled starting value. |
| `onValueChange` | `(value: string \| string[] \| undefined) => void` | Fires on every change. Under single it can fire with undefined — pressing the pressed item clears the selection. |
| `disabled` | `boolean` | Disables every item in the group. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
### `ToggleGroupItem`
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Required. What this item contributes. |
| `disabled` | `boolean` | Disables this item alone. |
| `Text` | `string` | The label. Drawn by TextSlot as a child TextLabel, not by the button. |
| `children` | `React.ReactNode` | An alternative to Text — an icon, a row, whatever the item should contain. |
| `className` | `ClassName` | Threaded into the item recipe's className slot inside the component. |
## `className` is written out on both branches
This is the component that turned a Vela rule into a convention. The root is rendered twice, once
per arm of `type`:
```tsx
const className = toggleGroupVariants.root({ className: props.className });
{props.type === "multiple" ? (
) : (
)}
```
The obvious tidy-up — folding `className` into the shared `passthrough` bag — is the thing that
breaks it.
> **Vela rewrites the call sites it can see**
>
> A `className` written as an **attribute** is a call site the transformer resolves. A `className`
> tucked inside a spread is just a key in an object; it reaches the primitive as a raw string prop and
> is dropped in silence, with no diagnostic. The component renders unstyled and nothing says why.
>
> Write it as an attribute. Every component in the registry does, including the ones where a spread
> would read better.
Two branches rather than a cast, because the primitive's props are a discriminated union over `type`
and the mirrored value is the union's full width. One branch per arm keeps the narrowing honest.
## The pressed state, and where it lands
The [mirrored state](https://docs.astra-void.xyz/facet/components/checkbox.md#the-state-is-mirrored-not-reached-for) is the
group's value, held with `useControllableState` and handed to each item through a Facet context —
Lattice's own is private, and each item's surface changes with whether it is pressed.
```tsx
className={toggleGroupVariants.item({
className: cn(pressed && "bg-accent hover:bg-accent", disabled && "opacity-50", props.className),
})}
```
`hover:bg-accent` is repeated on the pressed branch on purpose. The base recipe has
`hover:bg-muted`, and last-token-wins means the later `hover:bg-accent` replaces it — without it, a
pressed item would go *lighter* under the cursor than it is at rest.
The label's colour is a second class on a second instance:
```tsx
```
Nothing inherits, so the surface and the text state their halves separately — the same shape as
[`Alert`](https://docs.astra-void.xyz/facet/components/alert.md#variant-goes-on-every-part) and
[`Text field`](https://docs.astra-void.xyz/facet/components/text-field.md#invalid-is-stated-twice-and-that-is-not-a-bug).
## `toggle` is not in the registry
There is no standalone `Toggle`, and it is not an oversight: `@lattice-ui/react-toggle` does not
exist. A single pressed button is exactly the controlled/uncontrolled state logic that belongs in
Lattice rather than in a file you copy, so it waits for the primitive.
A one-item group covers it today:
```tsx
```
---
# Dialog
> The first layered component — a provider your app has to have, a panel that is not the primitive's content, and the one class in the registry that names a colour.
Source: https://docs.astra-void.xyz/facet/components/dialog/
```bash
npx facet-rbxts add dialog
```
Copies `ui/dialog.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0`, `@lattice-ui/react-dialog@^0.8.0` and
`@lattice-ui/react-layer@^0.8.0`.
```tsx
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../shared/ui/dialog";
```
> **This one needs a provider above your app**
>
> `Dialog.Portal` reads the `PlayerGui` it renders into from a **strict** context. Without a
> `PortalProvider` the dialog compiles, type-checks, ships — and throws the first time a player opens
> it. `facet add dialog` offers to write the wrapper into your client entry; the details are in
> [The provider](#the-provider) below.
_Interactive preview: Open on load. The ✕ should sit at the panel's right edge — Loom does not lay out self-end, so here it does not, which is one of the things only Studio can answer._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `Dialog`, `DialogTrigger`, `DialogPortal`, `DialogClose` | — | Re-exported from Lattice unstyled |
| `DialogOverlay` | `TextButton` | `bg-black/80` |
| `DialogContent` | `Frame` | `flex-col gap-4 w-96 h-fit mx-auto my-auto rounded-lg border border-border bg-background p-6` |
| `DialogHeader` | `Frame` | `flex-col w-full h-fit gap-2` |
| `DialogFooter` | `Frame` | `flex-row items-center justify-end w-full h-fit gap-2` |
| `DialogTitle` | `TextLabel` | `w-full h-fit whitespace-normal text-left text-lg font-semibold text-foreground` |
| `DialogDescription` | `TextLabel` | `w-full h-fit whitespace-normal leading-tight text-left text-sm font-normal text-muted-foreground` |
The four re-exports are `DialogPrimitive.Root`, `.Trigger`, `.Portal` and `.Close`, passed through
without a recipe — the trigger is whatever you put in it, exactly as shadcn does. Reached for bare,
`DialogTrigger` is a `textbutton` with Roblox's defaults neutralized and no size of its own, so give
it a `className` with both axes resolved, or use `asChild` around a
[`Button`](https://docs.astra-void.xyz/facet/components/button.md).
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
That is `DialogContent`, `DialogHeader` and `DialogFooter`. The two text parts forward onto a
`TextLabel`, and `DialogOverlay` onto a `TextButton`.
## The provider
```tsx
import { PortalProvider } from "@lattice-ui/react-layer";
```
One wrapper, at the client entry, for the whole app — not per dialog. Every layered component in the
registry will declare the same one, so this edit happens once.
> **Why the CLI edits a file for this and nothing else**
>
> `facet init` writes `vela.config.ts` only when there is none, and merely *reports* on `tsconfig.json`
> rather than editing it: both files belong to you, and a pattern-matched edit that mangles one is
> worse than a printed snippet.
>
> This is the exception, because every other thing the CLI reports is a **build-time** failure. A
> missing transformer means every class is inert on the next `rbxtsc`. A missing token is a Vela
> diagnostic. Both are loud, and both land in front of the person who just ran the command.
>
> A missing `PortalProvider` is none of those. It fails at runtime, in production, when a player
> presses the button — and by then the snippet scrolled past hundreds of lines of package-manager
> output. So `facet add` parses your entry, asks, and writes it. See the
> [CLI reference](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider).
`facet doctor` notices when the provider goes missing again.
## Props
### `DialogContent`
| Prop | Type | Description |
| --- | --- | --- |
| `overlayClassName` | `ClassName` | Styles the dim behind the panel. Spelled this way on purpose — see The scrim below. |
| `showClose` | `boolean` | The ✕ in the panel. Pass false for a dialog whose only way out is a footer button. |
| `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Fires before an outside press dismisses. event.preventDefault() keeps the dialog open. |
| `onInteractOutside` | `(event: LayerInteractEvent) => void` | The same, for any outside interaction. |
| `className` | `ClassName` | Threaded into the panel recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
`Dialog` takes `open`, `defaultOpen`, `onOpenChange` and `modal` from Lattice. `DialogHeader` and
`DialogFooter` take `children` and `className`; the two text parts take `Text` and `className`.
## The panel is a frame inside `Dialog.Content`
This is structural rather than stylistic, and it is the shape the rest of the layered tier will
inherit:
```tsx
```
`Dialog.Content` forces `Size` on its own host so the layer spans the screen, **and** it takes the
first host element under it as the boundary an outside press is measured against. A `className` on
the primitive itself fights the first — and, through the `UICorner` Vela prepends for `rounded-lg`,
quietly becomes the second.
So the styled panel is a child, and its own centring is two classes:
```
mx-auto my-auto
```
Vela lowers each to `AnchorPoint` 0.5 plus `Position` 0.5 on that axis. It works because the
primitive's content host spans the layer and lays nothing out, so this frame positions itself inside
it.
## The scrim
```tsx
overlay: fv("bg-black/80"),
```
The one class in the registry that names a colour instead of a role, against
[rule 8](https://docs.astra-void.xyz/facet/guides/component-conventions.md#8-roles-never-ramp-steps). It is deliberate: a scrim
has to darken whatever is under it *in every theme*, and not one of Facet's nineteen roles is dark in
both modes — that is what makes them roles.
| candidate | dark | light |
| --- | --- | --- |
| `bg-background/80` | zinc-950 ✓ | white ✗ |
| `bg-foreground/80` | zinc-50 ✗ | zinc-950 ✓ |
| `bg-muted/80` | zinc-800 ✓ | zinc-100 ✗ |
A token was not added for it either. A token is a published surface — `facet doctor` checks a
project's theme against the tokens each installed component names, `@facet-ui/theme` ships the
defaults, every upgrade inherits it — which is a large permanent commitment for one class in one
component, encoding something ("a scrim is dark") nobody will want to retheme. If `sheet` and
`drawer` turn out to want it too, that is when the argument restarts.
### `overlayClassName`, not `className`
```tsx
```
The name is the point. Vela intercepts a prop *named* `className` at the call site and hands the
component the resolved properties rather than the string, so a class routed through a second
component's `className` is overwritten by that component's own recipe.
`overlayClassName` is not `className`, so it arrives intact and merges into the single expression
where the overlay actually resolves. That is
[the `TextSlot` trap](https://docs.astra-void.xyz/facet/guides/text-and-labels.md#textslot-takes-no-classname) one level up, and
it is why `DialogContent` renders `DialogPrimitive.Overlay` directly instead of reaching for its own
`DialogOverlay`.
The recipe is also exported as `dialogVariants.overlay`, and the file is yours once copied.
## The corner ✕ is not a corner ✕
shadcn floats the close button over the panel's top-right. This one takes its own line at the top,
pushed right by `self-end`:
```tsx
close: fv("size-6 self-end rounded-md text-sm font-normal text-muted-foreground hover:bg-accent"),
```
A `UIListLayout` positions **every** child it has, so a floating child inside a `flex-col` panel is
not expressible without a second frame purely to escape the layout — which is the wrapper
[rule 5](https://docs.astra-void.xyz/facet/guides/component-conventions.md#5-layout-is-an-instance-not-a-property) says not to
add. `self-end` is a `UIFlexItem`, which is the in-layout way to say the same thing.
`showClose={false}` turns it off for a dialog whose only exit is a footer button.
> **Where the ✕ lands is not something the preview can show**
>
> Loom's `Enum` table has no `ItemLineAlignment`, so it neither reads nor lays out what `self-end`
> lowers to — the glyph sits where the list put it, at the left. The docs' Facet gallery backfills the
> enum so the scene renders at all rather than crashing.
>
> Whether the ✕ actually reaches the panel's right edge, whether the panel lands centred, and whether
> the dim covers the screen beneath it are the three geometry questions
> [Studio has not answered yet](https://docs.astra-void.xyz/facet/getting-started/scope-and-status.md#what-is-not-covered) for this
> component.
---
# Accordion
> Seven recipe entries, two contexts, and a divider that moved to the root to survive Roblox.
Source: https://docs.astra-void.xyz/facet/components/accordion/
```bash
npx facet-rbxts add accordion
```
Copies `ui/accordion.tsx`, plus `lib/utils.ts` and `lib/text.tsx`. Needs
`@facet-ui/react-variants`, `@lattice-ui/react-runtime@^0.8.0` and
`@lattice-ui/react-accordion@^0.8.0`.
```tsx
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../shared/ui/accordion";
```
_Interactive preview: One item open, one closed, with a single rule between them — drawn by divide-y on the root, for the reason below._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `Accordion` | `Frame` | `flex-col w-full h-fit divide-y divide-border` |
| `AccordionItem` | `Frame` | `flex-col w-full h-fit` |
| `AccordionTrigger` | `TextButton` | `flex-row items-center justify-between w-full h-fit py-4` |
| `AccordionContent` | `Frame` | `flex-col gap-2 w-full h-fit pb-4` |
The trigger's label and the chevron are two more instances inside it, and the content's text is one
more inside that. Seven recipe entries, four exported parts — the rest take no props of their own,
and every export would cost a Luau register. See
[the register limit](https://docs.astra-void.xyz/facet/guides/component-conventions.md#6-flat-named-exports).
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
That is `AccordionItem` and `AccordionContent`. `Accordion` and `AccordionTrigger` take no
passthrough bag.
## The rule between items lives on the root
shadcn draws the divider with `border-b` on each item. That class cannot work on Roblox, so this
recipe moves the rule up to the root:
```tsx
root: fv("flex-col w-full h-fit divide-y divide-border"),
item: fv("flex-col w-full h-fit"),
```
`border-*` lowers to a `UIStroke`, and **a `UIStroke` outlines the whole instance**. There are no
per-side strokes, so Vela treats `border-b` as
[unsupported and drops it](https://docs.astra-void.xyz/vela-rbxts/guides/colors-and-surfaces.md#borders) — silently here, because
a recipe's classes are resolved by the Vela runtime rather than at compile time, so the compiler's
unsupported-border diagnostic never fires. What would survive is `border-border`, which sets the
stroke's colour and clears its transparency, and a `UIStroke` at its default thickness of 1 draws on
all four sides. Every item would come out **boxed**, not underlined.
`divide-y` has no such problem. Vela lowers it to real one-pixel frames interleaved between the
root's children, painted by `divide-border`. Two items get one rule between them and nothing under
the last — the same thing shadcn's `border-b last:border-b-0` renders.
> **Changing the divider in your copy**
>
> The file is yours, so:
>
> - **A heavier rule** is `divide-y-2` on the root — the number is the thickness in pixels, and the
> family takes `0`, `1`, `2`, `4` or `8`.
> - **No rule at all** — drop both `divide-*` classes.
> - **A rule you place yourself** — use [`Separator`](https://docs.astra-void.xyz/facet/components/separator.md) between items
> instead. That is a real one-pixel frame in the flow, and it is what the registry has for dividers
> elsewhere.
>
> What does not work is a smarter border class. `border-t`, `border-x` and every prefixed form are on
> the same unsupported list.
## Props
### `Accordion`
| Prop | Type | Description |
| --- | --- | --- |
| `type` | `"single" \| "multiple"` | single closes the previous item when the next opens; multiple lets them accumulate. |
| `value` | `string \| string[]` | Controlled open item(s). |
| `defaultValue` | `string \| string[]` | Uncontrolled starting state. |
| `onValueChange` | `(value: string \| string[]) => void` | Fires when an item opens or closes. |
| `collapsible` | `boolean` | In single mode, whether the open item can be clicked shut again. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
### The other parts
`AccordionItem` takes a required `value`, plus `disabled` and `className`. `AccordionTrigger` and
`AccordionContent` each take `Text`, `children` and `className`.
## Two contexts, and why the second one exists
The [mirrored state](https://docs.astra-void.xyz/facet/components/checkbox.md#the-state-is-mirrored-not-reached-for) is the set of
open values, held on `Accordion` with `useControllableState`. A Facet context hands `isOpen(value)`
down, because Lattice's own context is private and the chevron rotates with it.
The second context is `AccordionItemContext`, and it carries one boolean:
```tsx
const AccordionItemContext = React.createContext<{ open: boolean } | undefined>(undefined);
```
Without it, `AccordionTrigger` would need to know its item's `value` to ask the first context —
which means either passing `value` twice in the markup, or reaching into the primitive. The item
already knows; it hands its own answer down.
## The header carries no layout class
```tsx
```
No `flex-*` on the header, deliberately. A `flex-*` class lowers to a `UIListLayout` **sibling** next
to the header's single child — and the primitive types `children` as one element, so there is no
room for the layout instance beside it. Both axes are still declared, which is
[rule 1](https://docs.astra-void.xyz/facet/guides/component-conventions.md#1-declare-both-axes-always).
The same constraint is why the trigger's label and chevron arrive wrapped in a fragment: the
primitive wants one child, and there are two things to draw.
## The chevron is a rotated glyph
```tsx
```
Roblox has no icon font, so `▾` is a character and "up" is the same character turned over. That is a
[settled position](https://docs.astra-void.xyz/facet/guides/component-conventions.md#7-icons-are-text-glyphs-replaceable-by-slot),
and swapping it for an `imagelabel` with your own artwork changes nothing else in the file.
---
# Alert
> Three parts, one recipe object, and a variant that has to be repeated on every part that draws something.
Source: https://docs.astra-void.xyz/facet/components/alert/
```bash
npx facet-rbxts add alert
```
Copies `ui/alert.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants` and
`@lattice-ui/react-runtime`.
```tsx
import { Alert, AlertTitle, AlertDescription } from "../shared/ui/alert";
```
_Interactive preview: Both variants. Note that destructive is stated three times, once per part._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `Alert` | `Frame` | `flex-col w-full h-fit gap-1 rounded-lg border p-4` |
| `AlertTitle` | `TextLabel` | `w-full h-fit whitespace-normal text-left text-sm font-medium` |
| `AlertDescription` | `TextLabel` | `w-full h-fit whitespace-normal leading-tight text-left text-xs font-normal` |
Flat named exports, like [Card](https://docs.astra-void.xyz/facet/components/card.md#the-parts) — this is source you paste and
edit, so each part reads and deletes on its own.
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
`Alert` forwards onto a `Frame` and takes `children`. The two text parts forward onto a `TextLabel`
and take `Text`.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `variant` | `"default" \| "destructive"` | Takes it on every part, not just the root. Defaults to default. See below — this is the thing to notice if you are coming from shadcn. |
| `Text` | `string` | Text parts only. Drawn as the instance's own Text — these are leaves. |
| `children` | `React.ReactNode` | Root only. |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
## `variant` goes on every part
This is the one surprise in the component, and it is not an oversight:
```tsx
```
In shadcn the variant is set once on the root and a descendant selector colours the title. Roblox
has no descendant selectors and **nothing inherits** — a `TextLabel`'s colour lives on that
`TextLabel` — so `variant` has to reach every part that draws something.
Verbose on purpose. The alternative is a React context, and a context is a thing you then own and
have to keep wired up through your own edits, for a component whose entire body is three instances.
If you find the repetition annoying in your project, the file is yours: add the context, or collapse
the three parts into one component that takes two strings.
> **Forgetting it on a part is silent**
>
> `` with a plain ` ` compiles, renders, and gives you a
> destructive border around a default-coloured title. Nothing warns — there is no relationship between
> the parts for anything to check.
| `variant` | Root | Title | Description |
| --- | --- | --- | --- |
| `default` | `bg-card border-border` | `text-card-foreground` | `text-muted-foreground` |
| `destructive` | `bg-card border-destructive` | `text-destructive` | `text-destructive` |
The destructive surface is the *same* `bg-card` as the default — only the border and the text
change. A red fill behind body text is a contrast problem the theme cannot solve, and the border
carries the signal on its own.
## Three parts, one recipe object
```tsx
export const alertVariants = {
root: fv("flex-col w-full h-fit gap-1 rounded-lg border p-4", { variants: { … } }),
title: fv("w-full h-fit whitespace-normal text-left text-sm font-medium", { variants: { … } }),
description: fv("w-full h-fit whitespace-normal leading-tight text-left text-xs font-normal", { variants: { … } }),
};
```
One object rather than three `export const`s, for the reason
[Card](https://docs.astra-void.xyz/facet/components/card.md#six-parts-one-recipe-object) is written the same way: every exported
name costs a Luau register, Vela inlines its runtime into any file with a computed `className`, and
`card` went over the 200-register limit and stopped loading entirely. Three parts is nowhere near
that ceiling — the shape is the convention, kept because it is free.
See [the register limit](https://docs.astra-void.xyz/facet/guides/component-conventions.md#6-flat-named-exports).
## `w-full h-fit`, all the way down
Every part carries it, exactly as `card` does: **width from the parent, height from the content**.
A part that fails to resolve a height collapses the alert above it.
`whitespace-normal` and `text-left` are on both text parts because Roblox centres text and keeps it
on one line by default — an alert description is the case where that matters most, since it is the
part most likely to wrap.
`font-normal` on the description is not redundant next to `font-medium` on the title. Vela leaves
`FontFace` alone when no `font-*` token appears, and Roblox's untouched default is LegacyArial —
which is [the bug that shipped in `card`](https://docs.astra-void.xyz/facet/components/card.md#wrapping-and-alignment-are-classes)
before anyone opened it in Studio.
## The root keeps its background
```tsx
```
No `BackgroundTransparency: 1` on the root, unlike most components — the surface *is* the alert.
Only Roblox's 1px border is cleared, because `border` in the recipe is what draws the real one. The
two text parts do clear their backgrounds, since a `TextLabel` with an opaque default would paint a
box behind every line.
That is the same rule [Separator](https://docs.astra-void.xyz/facet/components/separator.md#the-one-component-that-keeps-its-background)
follows, reached from the other direction.
---
# Avatar
> A circle drawn by the wrapper, because the primitive under it renders no instance at all.
Source: https://docs.astra-void.xyz/facet/components/avatar/
```bash
npx facet-rbxts add avatar
```
Copies `ui/avatar.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-avatar@^0.8.0`.
```tsx
import { Avatar, AvatarFallback, AvatarImage } from "../shared/ui/avatar";
```
_Interactive preview: Fallbacks only — Loom has no Roblox content pipeline to fetch an rbxthumb:// from, so the image half cannot be previewed._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `Avatar` | `Frame` | `size-10 rounded-full bg-muted overflow-hidden` |
| `AvatarImage` | `ImageLabel` | `size-full rounded-full` |
| `AvatarFallback` | `TextLabel` | `size-full rounded-full bg-muted text-sm font-medium text-muted-foreground text-center` |
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
That is `Avatar`. `AvatarImage` forwards onto an `ImageLabel` and `AvatarFallback` onto a `TextLabel`.
## The circle is drawn here, not by the primitive
`Avatar.Root` renders **no instance**. It tracks whether the image loaded and hands that answer to
`Image` and `Fallback`; it draws nothing. So unlike every other component in this tier, the visible
container is a plain ` ` in the copied file, and `Avatar.Root` wraps it.
That inverts the usual nesting:
```tsx
{props.children}
```
[`Progress`](https://docs.astra-void.xyz/facet/components/progress.md) is built the same way, for the same reason.
## Every part rounds itself
> **Roblox clips to a rectangle**
>
> `rounded-full` appears three times — on the root, on the image and on the fallback — and it is not
> redundant. A `UICorner` rounds the instance it is on; it does not clip children to that shape.
> Round only the parent and a child that paints its own background pokes square corners out of the
> circle.
>
> `overflow-hidden` on the root is `ClipsDescendants`, which clips to the root's *rectangle* — not to
> its corner radius. It keeps a too-large image inside the box; it does not round it.
## Props
### `Avatar`
| Prop | Type | Description |
| --- | --- | --- |
| `src` | `string` | The image source — an rbxassetid:// or rbxthumb:// URL. Read by the primitive, which decides whether the image or the fallback shows. |
| `delayMs` | `number` | How long to hold the fallback back while the image loads. Stops a flash of initials on a fast load. |
| `children` | `React.ReactNode` | AvatarImage and AvatarFallback, in that order. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
`AvatarFallback` takes `Text` and `className`; `AvatarImage` takes `className`.
## `size-10` is the only size there is
There is no `size` variant. `size-10` is stated on the root recipe, and a `className` at the call
site cannot change it — a class written there resolves to `Size` at that call site and the recipe
overwrites it, which is
[measured, not predicted](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
A bigger avatar is an edit to the copied file:
```tsx
export const avatarVariants = {
root: fv("size-14 rounded-full bg-muted overflow-hidden"),
…
};
```
Or a variant, if a project needs two of them — which is what the file is for.
---
# Badge
> A status pill that hugs its label — four variants, and a lesson in size-fit.
Source: https://docs.astra-void.xyz/facet/components/badge/
```bash
npx facet-rbxts add badge
```
Copies `ui/badge.tsx`, plus `lib/utils.ts` and `lib/text.tsx`. Needs `@facet-ui/react-variants` and
`@lattice-ui/react-runtime`.
```tsx
import { Badge } from "../shared/ui/badge";
```
_Interactive preview: Four variants, each hugging its own label._
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `Text` | `string` | The label. Drawn as a styled child textlabel through TextSlot; children renders instead when it is absent. |
| `variant` | `"default" \| "secondary" \| "destructive" \| "outline"` | Surface and label colour. Defaults to default. |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
| `children` | `React.ReactNode` | Composition — an icon glyph, a dot. Laid out in a row with gap-1 alongside nothing else, since Text and children are exclusive. |
Everything else is forwarded onto the `Frame`.
## Variants
| `variant` | Surface | Label |
| --- | --- | --- |
| `default` | `bg-primary` | `text-primary-foreground` |
| `secondary` | `bg-secondary` | `text-secondary-foreground` |
| `destructive` | `bg-destructive` | `text-destructive-foreground` |
| `outline` | `border border-input` | `text-foreground` |
Both recipes are exported — `badgeVariants` and `badgeLabelVariants` — because nothing inherits and
the label's colour has to live on the instance that draws the text.
## `size-fit` is the whole geometry
```tsx
export const badgeVariants = fv(
"flex-row items-center justify-center gap-1 size-fit rounded-full px-2 py-1",
{ variants: { … }, defaultVariants: { variant: "default" } },
);
```
There is no `h-*` and no `w-*` here. `size-fit` resolves both axes to automatic sizing, which is the
only correct answer for a pill that has to be exactly as wide as its text plus its padding. The rule
it satisfies is [declare both axes](https://docs.astra-void.xyz/facet/guides/component-conventions.md#1-declare-both-axes-always)
— `size-fit` is an answer on each, not an absence of one.
That also makes the badge the clearest case of the `AutomaticSize` chain: it can only measure itself
because `TextSlot` renders its label with `size-fit` too. A label with an unresolved axis and the
badge collapses with it.
`rounded-full` is a pill rather than a rounded rectangle; `px-2 py-1` insets the label without
growing the frame, which is exactly what `UIPadding` does and exactly why the frame needs `size-fit`
to grow around it.
---
# Card
> Six flat parts, one shared recipe object, and the Luau register limit that forced it.
Source: https://docs.astra-void.xyz/facet/components/card/
```bash
npx facet-rbxts add card
```
Copies `ui/card.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants` and
`@lattice-ui/react-runtime`.
```tsx
import {
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
} from "../shared/ui/card";
import { Button } from "../shared/ui/button";
```
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `Card` | `Frame` | `flex-col w-full h-fit rounded-lg border border-border bg-card` |
| `CardHeader` | `Frame` | `flex-col w-full h-fit gap-1 p-6` |
| `CardTitle` | `TextLabel` | `w-full h-fit whitespace-normal text-left text-xl font-semibold text-card-foreground` |
| `CardDescription` | `TextLabel` | `w-full h-fit whitespace-normal leading-tight text-left text-xs font-normal text-muted-foreground` |
| `CardContent` | `Frame` | `flex-col w-full h-fit gap-2 px-6 pb-6` |
| `CardFooter` | `Frame` | `flex-row items-center w-full h-fit gap-2 px-6 pb-6` |
Flat named exports, not `Card.Header`. Lattice uses namespace objects and that is right for a
library; this is source you paste and edit, so each part reads — and can be deleted — on its own.
_Interactive preview: All six parts, composed with Badge, Label and Button._
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
The frame parts (`Card`, `CardHeader`, `CardContent`, `CardFooter`) forward onto a `Frame` and take
`children`. The text parts (`CardTitle`, `CardDescription`) forward onto a `TextLabel` and take
`Text`.
| Prop | Type | Description |
| --- | --- | --- |
| `Text` | `string` | Text parts only. Drawn as the instance's own Text — these are leaves. |
| `children` | `React.ReactNode` | Frame parts only. |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
## `w-full h-fit`, all the way down
Every part carries it, and that is the entire layout strategy: **width comes from the parent, height
from the content**. Break the chain at any level — one part with no resolved height — and the card
above it collapses.
That is the `AutomaticSize` chain in its most visible form. A container set to hug its content can
only measure children that already know their own size, so it is not enough for `Card` to say
`h-fit`; every level under it has to answer too.
## Six parts, one recipe object
```tsx
export const cardVariants = {
root: fv("flex-col w-full h-fit rounded-lg border border-border bg-card"),
header: fv("flex-col w-full h-fit gap-1 p-6"),
title: fv("w-full h-fit whitespace-normal text-left text-xl font-semibold text-card-foreground"),
description: fv("w-full h-fit whitespace-normal leading-tight text-left text-xs font-normal text-muted-foreground"),
content: fv("flex-col w-full h-fit gap-2 px-6 pb-6"),
footer: fv("flex-row items-center w-full h-fit gap-2 px-6 pb-6"),
};
```
> **This is not a style choice**
>
> Vela inlines its whole runtime into every file with a computed `className`, which leaves a component
> only a slice of Luau's 200-register limit for its own module-scope locals. Six separate
> `export const`s put this file over it and the module stopped loading entirely —
> `Out of local registers when trying to allocate CardHeader`. Each exported name costs a register; one
> object costs one.
>
> Vela 0.9.0 scoped that runtime into a single initializer and the emitted files dropped from ~106
> module-scope locals to ~24, which is why `^0.9.0` is the floor the CLI installs. The object shape
> stayed: it is cheap, and the headroom is worth keeping.
## Wrapping and alignment are classes
Roblox centres text and leaves it on one line by default. `text-left` and `whitespace-normal`
correct both, on every text part. Without them a card description is one centred line running off
the edge.
`leading-tight` on the description is the one place `card` leans on a Vela family that only landed
in 0.8.0 on the computed-`className` path — below that floor it compiles and silently does nothing.
`font-normal` on the description looks redundant next to `font-semibold` on the title, and is not.
Vela leaves `FontFace` alone when no `font-*` token appears, and Roblox's untouched default is
LegacyArial. This exact line is why: the description rendered in Arial next to a SourceSansPro
title, inside the same header, for as long as nobody had opened it in Studio.
## One layout per instance
`flex-col`, `items-*`, `justify-*` and `gap-*` all lower onto a single `UIListLayout` child, and one
instance can hold one layout. So a part that sets any of them owns the arrangement of its children;
a consumer who wants a different one **replaces** the part's layout classes rather than adding to
them.
If you find yourself wanting a second layout inside `CardContent`, do not add a wrapper frame for
it. Restructure the parts — that is what they are for, and they are yours to restructure.
---
# Kbd
> A key cap — and the one place in the registry where font-* picks a typeface on purpose.
Source: https://docs.astra-void.xyz/facet/components/kbd/
```bash
npx facet-rbxts add kbd
```
Copies `ui/kbd.tsx`, plus `lib/utils.ts` and `lib/text.tsx`. Needs `@facet-ui/react-variants` and
`@lattice-ui/react-runtime`.
```tsx
import { Kbd } from "../shared/ui/kbd";
```
_Interactive preview: A single letter and a word, each cap exactly as wide as what it holds._
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `Text` | `string` | The key. Drawn as a styled child textlabel through TextSlot; children renders instead when it is absent. |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
| `children` | `React.ReactNode` | Composition, when the cap holds something that is not a string. Exclusive with Text. |
Everything else is forwarded onto the `Frame`.
## `font-mono` is a family, not a weight
```tsx
export const kbdLabelVariants = fv("text-xs font-mono text-muted-foreground");
```
> **The one deliberate typeface in the registry**
>
> Every other component carries a `font-*` token for a defensive reason: Vela leaves `FontFace` alone
> when no `font-*` appears, and Roblox's untouched default is LegacyArial. So `font-normal` and
> `font-medium` elsewhere are there to *avoid* a default, and they name a weight.
>
> `font-mono` is not that. Vela resolves it against `theme.fontFamily` — to RobotoMono — where the
> weights resolve against something else entirely. This is the one place in the registry where the
> mandatory `font-*` is choosing a typeface because the component wants that typeface.
A key cap in a proportional face reads as a word in a box. In a monospace face it reads as a key.
That is the whole justification, and it is worth the one exception.
See [Text and labels](https://docs.astra-void.xyz/facet/guides/text-and-labels.md) for why `font-*` is mandatory at all.
## `size-fit` with padding, not a fixed square
```tsx
export const kbdVariants = fv(
"flex-row items-center justify-center size-fit rounded-md border border-border bg-muted px-1.5 py-0.5",
);
```
The obvious implementation of a key cap is a square — keys are square. It is wrong here, because a
cap has to hold `Ctrl` as readily as `E`, and Roblox will not infer that width for you.
`size-fit` plus `px-1.5` gives a cap that is as wide as its content and no wider, so `E` comes out
nearly square and `Ctrl` comes out a rounded rectangle. Same recipe, no variants, no `w-*`.
That makes `kbd` the same `AutomaticSize` story as [Badge](https://docs.astra-void.xyz/facet/components/badge.md#size-fit-is-the-whole-geometry):
the frame can only measure itself because `TextSlot` renders its label with `size-fit` too. Break
that and the cap collapses to nothing.
If you want uniform caps in a row — a shortcut legend where the columns should line up — ` ` will **not** do it: a class written at a Vela-compiled call site
[never reaches the component](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
Add a `size` variant to your copy of the file, or wrap each cap in a fixed-width frame.
## What it does not do
There is no `size` variant and no pressed state. A key cap is a static label for a key that exists
on a keyboard; a thing that responds to being clicked is a
[Button](https://docs.astra-void.xyz/facet/components/button.md). Composing them — a cap inside a button's `children` — is the
supported way to build a rebindable-key row, and it keeps the interaction in the component that
already handles it.
---
# Progress
> A track and a fill, where the fill's width is the value — and this file names neither number.
Source: https://docs.astra-void.xyz/facet/components/progress/
```bash
npx facet-rbxts add progress
```
Copies `ui/progress.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-progress@^0.8.0`.
```tsx
import { Progress } from "../shared/ui/progress";
```
_Interactive preview: Two determinate bars and the indeterminate sweep. The sweep is motion, not a class._
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error. The primitive owns `Size` on the indicator, which it animates from the value, so values you pass for it are ignored.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `number` | How far along. Read against max. |
| `max` | `number` | The end. Defaults to 100. |
| `indeterminate` | `boolean` | Sweep the indicator back and forth instead of mapping value — for work with no known end. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
There is no `Text`, no label and no percentage readout. A progress bar in this registry is two
rectangles; the words above it are a [`Label`](https://docs.astra-void.xyz/facet/components/label.md) you place yourself.
## The primitive draws nothing either
```tsx
```
`Progress.Root` renders no instance — it turns `value` and `max` into a ratio and nothing else — so
the track is a plain ` ` in the copied file, with the primitive wrapped around it. Same
inversion as [`Avatar`](https://docs.astra-void.xyz/facet/components/avatar.md#the-circle-is-drawn-here-not-by-the-primitive).
## The indicator has no width
```tsx
export const progressVariants = {
root: fv("h-2 w-full rounded-full bg-secondary overflow-hidden"),
indicator: fv("rounded-full bg-primary"),
};
```
Two classes on the indicator, and neither is a size. Its `Size` **is** the value: Lattice's motion
owns the property, animates it as the number moves, and sweeps it end to end when `indeterminate`.
> **A width class here would win the first frame and lose every one after**
>
> That is the failure mode worth naming, because it is the one that looks like it works. Declaring
> `w-1/2` on the indicator renders a half-full bar on mount and then gets overwritten as soon as the
> value changes. [Textarea](https://docs.astra-void.xyz/facet/components/textarea.md#the-input-declares-no-height) and
> [Slider](https://docs.astra-void.xyz/facet/components/slider.md#the-track-has-no-flex--and-that-is-the-point) omit a property for
> the same reason.
`overflow-hidden` on the root is what keeps the fill's square end inside the rounded track:
`ClipsDescendants` clips to the rectangle, and the indicator's own `rounded-full` does the corners.
## `indeterminate` is not a value
` ` ignores `value` entirely. Use it for work whose end you cannot measure
— a server round trip, an asset load with no progress signal — rather than passing a fake number
that never advances.
---
# Scroll area
> A viewport with a drawn scrollbar — and the component whose height has to come from above it.
Source: https://docs.astra-void.xyz/facet/components/scroll-area/
```bash
npx facet-rbxts add scroll-area
```
Copies `ui/scroll-area.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-scroll-area@^0.8.0`.
```tsx
import { ScrollArea } from "../shared/ui/scroll-area";
…
```
_Interactive preview: Seven rows in a 128px box. The height and the border are on the wrapper, not on the ScrollArea._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `ScrollArea` | `Frame` | `w-full h-full overflow-hidden` |
| `ScrollBar` | `Frame` | `rounded-full`, plus the edge insets for its orientation |
`ScrollArea` renders a `ScrollBar` for you. The export exists for the case where you want a second
one on the other axis.
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
## The height comes from the parent
> **`h-full` is in the recipe, so a className here cannot set a height**
>
> The root is `w-full h-full`: the component fills whatever it is put inside. A `className` written at
> the call site is resolved *there* and arrives as instance properties, which the recipe then
> overwrites for every property it also names — and it names `Size` on both axes.
>
> This is measured. A `` inside a 200px-tall parent renders 200px tall.
> The mechanism is in
> [overriding from the call site](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
So the size goes on a wrapper, and so does anything else the recipe states. What *does* cross from a
call-site class is whatever the recipe leaves alone — `rounded-md` and `border-border` reach the
instance fine, because the root recipe names neither a corner nor a stroke.
A scroll area that hugs its content has nothing to scroll, so something above it has to resolve a
height either way. Putting it on a wrapper frame is the one arrangement that always works.
## Props
### `ScrollArea`
| Prop | Type | Description |
| --- | --- | --- |
| `type` | `"auto" \| "always" \| "scroll"` | auto shows the bar while scrolling, always keeps it, scroll matches the platform. Read by the primitive. |
| `scrollHideDelayMs` | `number` | How long the bar lingers after a scroll stops. |
| `children` | `React.ReactNode` | What scrolls. Give it h-fit so it can be taller than the viewport. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see above. |
### `ScrollBar`
| Prop | Type | Description |
| --- | --- | --- |
| `orientation` | `"vertical" \| "horizontal"` | Which edge it pins to, and which axis the thumb sizes on. Defaults to vertical. |
| `className` | `ClassName` | Threaded into the scrollbar recipe's className slot inside the component. |
## Two scrollbars, one of them invisible
```tsx
viewport: fv("size-full scrollbar-none"),
```
The viewport is a Roblox `ScrollingFrame`, which draws a scrollbar of its own.
`scrollbar-none` hides it, because the visible one is the `ScrollBar` beside it — a frame Lattice
sizes and positions from the scroll ratio, and fades after `scrollHideDelayMs`.
Losing that class gives you both bars at once, which is the failure mode to recognise.
## The root has no `flex-*`
The scrollbar is pinned to an edge with inset classes — `right-0 top-0 h-full w-2` for a vertical
one — and a `UIListLayout` would pull it into the flow and lay it out beside the viewport instead.
[Slider](https://docs.astra-void.xyz/facet/components/slider.md#the-track-has-no-flex--and-that-is-the-point) omits its layout for
the same reason.
---
# Separator
> A one-pixel divider — and the only component whose background is the point.
Source: https://docs.astra-void.xyz/facet/components/separator/
```bash
npx facet-rbxts add separator
```
Copies `ui/separator.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants` and
`@lattice-ui/react-runtime`.
```tsx
import { Separator } from "../shared/ui/separator";
```
_Interactive preview: Both orientations. The vertical one takes its height from the row._
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | Which axis is one pixel. Defaults to horizontal. |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
Everything else is forwarded onto the `Frame`. There is no `Text` and no `children` — this is a
frame with a colour.
## The recipe
```tsx
export const separatorVariants = fv("bg-border", {
variants: {
orientation: {
horizontal: "w-full h-px",
vertical: "h-full w-px",
},
},
defaultVariants: { orientation: "horizontal" },
});
```
Both axes are concrete on each orientation — `w-full h-px`, `h-full w-px` — so this is the one
component in the registry that needs no automatic sizing at all. `h-px` is one literal pixel, not a
spacing step.
`h-full` on the vertical orientation means the parent has to have a resolved height. Inside a
`flex-row` container that hugs its content, that is not automatic — give the container a height. Not
the separator: a `className` at the call site resolves to `Size` there and the recipe, which names
both axes, overwrites it. See
[overriding from the call site](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
## The one component that keeps its background
Every other Facet component clears Roblox's defaults before styling:
```tsx
const NEUTRAL_PROPS = {
BorderSizePixel: 0,
};
```
No `BackgroundTransparency: 1` here, unlike everywhere else — the background **is** the separator.
`bg-border` is the whole visual, and clearing it would leave a one-pixel invisible frame.
---
# Skeleton
> A placeholder block — and the component defined by what it deliberately does not do.
Source: https://docs.astra-void.xyz/facet/components/skeleton/
```bash
npx facet-rbxts add skeleton
```
Copies `ui/skeleton.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants` and
`@lattice-ui/react-runtime`.
```tsx
import { Skeleton } from "../shared/ui/skeleton";
// A line of text, as wide as whatever contains it.
// Narrower, because the parent is narrower — not because the skeleton was told.
```
_Interactive preview: Three identical s. The width difference is entirely in their parents._
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
## Props
| Prop | Type | Description |
| --- | --- | --- |
| `className` | `ClassName` | Threaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see below, because for this component it is the whole story. |
Everything else is forwarded onto the `Frame`. There is no `Text` and no `children` — this is a
frame with a colour, like [Separator](https://docs.astra-void.xyz/facet/components/separator.md).
## You cannot resize it from the call site
> **` ` does nothing**
>
> This is measured in the preview above, not predicted. A `className` written at a Vela-compiled call
> site is [consumed there and never reaches the component](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site),
> so three skeletons written with three different classes come out identical. No diagnostic, on either
> side.
>
> It bites harder here than anywhere else in the registry, because `className` is the only prop
> `Skeleton` has. A component with `variant` and `size` still has an API when `className` is inert;
> this one does not.
`Size` does not survive as a passthrough prop either — the recipe's own `w-full h-4` resolves *after*
the spread and overwrites it. So there are two things that actually work:
**State the width on the parent.** `w-full` means "my parent's width", so a skeleton in a `w-40`
frame is 160px wide. That is what the preview does, and it costs nothing.
**Edit the file for anything else.** Height, corner radius, a circular avatar placeholder — those
live in the recipe, and the recipe is in your project:
```tsx title="src/shared/ui/skeleton.tsx"
export const skeletonVariants = fv("w-full h-4 rounded-md bg-muted", {
variants: {
shape: {
line: "",
block: "h-8",
avatar: "size-12 rounded-full",
},
},
defaultVariants: { shape: "line" },
});
```
That is [the model's answer](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#what-that-means-for-a-copied-component)
rather than a workaround: a variant you added is a variant that works, because it is resolved inside
the file rather than at the call site.
## There is no pulse
shadcn's skeleton is `animate-pulse rounded-md bg-muted`. This is the same thing minus the pulse,
and that absence is the component's only real design decision.
> **Vela has no animate-* family**
>
> The families it does have — `transition-*`, `duration-*`, `ease-*` — animate a *change*: a property
> moving from one resolved value to another, on hover or on a state flip. A pulse is a loop with no
> triggering change, so nothing in the class vocabulary expresses it.
>
> Driving it from the component instead means a `useEffect` and a tween, in a file that is otherwise a
> recipe and a frame. That is runtime behaviour nobody can check by reading the source — the exact
> thing this registry is built to avoid — so it stays out until Facet has an animation primitive worth
> putting under it.
If you want the pulse in your project today, the file is yours: `TweenService` against
`BackgroundTransparency` on a `useEffect` is about eight lines, and you are then the person who owns
those eight lines rather than inheriting them from a registry.
## The recipe
```tsx
export const skeletonVariants = fv("w-full h-4 rounded-md bg-muted");
```
No variants, no parts. The default size is **a line of text** — `h-4` — because the overwhelmingly
common use is standing in for one.
Both axes are declared, which is the [first convention](https://docs.astra-void.xyz/facet/guides/component-conventions.md#1-declare-both-axes-always).
`w-full` defers the width to the parent and `h-4` states the height outright, so the component always
has a resolved size and never collapses the layout above it — which matters more than usual for a
placeholder, since a skeleton exists precisely when there is no content to measure.
The source comment argues that a consumer's override wins because it lands after the recipe in
`className`. That is true of `fv()`'s composition and false in practice: the string never arrives.
See [above](#you-cannot-resize-it-from-the-call-site).
## It keeps its background
```tsx
const NEUTRAL_PROPS = {
BorderSizePixel: 0,
};
```
No `BackgroundTransparency: 1`, for the same reason as
[Separator](https://docs.astra-void.xyz/facet/components/separator.md#the-one-component-that-keeps-its-background) — `bg-muted`
is the entire visual, and clearing the background would leave an invisible frame taking up space.
---
# Tabs
> A list, some triggers, and switched panels — plus one prop you have to pass or nothing looks selected.
Source: https://docs.astra-void.xyz/facet/components/tabs/
```bash
npx facet-rbxts add tabs
```
Copies `ui/tabs.tsx`, plus `lib/utils.ts` and `lib/text.tsx`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-tabs@^0.8.0`.
```tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../shared/ui/tabs";
…
…
```
_Interactive preview: Two live triggers and one disabled. The selected trigger takes bg-background; its label takes text-foreground._
## The parts
| Part | Renders | Classes |
| --- | --- | --- |
| `Tabs` | `Frame` | `flex-col gap-2 w-full h-fit` |
| `TabsList` | `Frame` | `flex-row items-center justify-center gap-1 w-fit h-9 rounded-lg bg-muted p-1` |
| `TabsTrigger` | `TextButton` | `flex-row items-center justify-center h-7 w-fit px-3 rounded-md transition duration-150` |
| `TabsContent` | `Frame` | `flex-col gap-2 w-full h-fit` |
Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.
That is `Tabs`, `TabsList` and `TabsContent`. `TabsTrigger` has no passthrough bag — it renders a
button and a label, and there is no single instance for an unknown prop to land on.
## Pass `defaultValue`
> **Without one, nothing styles as selected**
>
> Lattice's `Tabs.Root` has its own fallback: with no value it selects the first enabled trigger. The
> [mirrored state](https://docs.astra-void.xyz/facet/components/checkbox.md#the-state-is-mirrored-not-reached-for) in this file
> cannot see that decision — it holds `undefined`, so `selected` is false for every trigger, and the
> list renders with nothing highlighted while the content panel below it switches correctly.
>
> It compiles, it works, and it looks broken. Give `Tabs` a `defaultValue` (or a controlled `value`).
This is the sharp edge of mirroring a private context: the mirror is only as good as what it was
told, and a fallback that lives inside the primitive is exactly what it was not told.
## Props
### `Tabs`
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Controlled value. Pass it with onValueChange. |
| `defaultValue` | `string` | Uncontrolled starting value. Effectively required — see above. |
| `onValueChange` | `(value: string) => void` | Fires when a different trigger is selected. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |
### `TabsTrigger` and `TabsContent`
`TabsTrigger` takes a required `value`, plus `disabled`, `Text`, `children` and `className`.
`TabsContent` takes a required `value`, plus `children` and `className`.
## The selected state lands on two instances
```tsx
```
The surface takes `bg-background`; the label takes `text-foreground` over its resting
`text-muted-foreground`. Two classes on two instances, because nothing inherits — the same shape as
[`Toggle group`](https://docs.astra-void.xyz/facet/components/toggle-group.md#the-pressed-state-and-where-it-lands).
The state classes sit **inside** the recipe's slot and ahead of `props.className`, which is
[the one rule](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#the-one-rule): resolution is last-token-wins, so
anything after the consumer's class is an override they cannot undo.
## `w-fit` on the list, `h-9` on the list
The list hugs its triggers horizontally and states a fixed height, which is what gives the pill its
shape: `p-1` insets the `h-7` triggers by 4px on each edge inside the `h-9` track. Changing one
number means changing the other — there is no variant, because the file is short enough to edit.
---
# Theming
> Nineteen semantic tokens, four neutral ramps, and why a build carries exactly one mode.
Source: https://docs.astra-void.xyz/facet/guides/theming/
Roblox has no CSS variables. The indirection that makes shadcn/ui themeable — `:root { --primary }`,
re-read by the browser on every paint — has no equivalent, so Facet puts it one layer down, in
`vela.config.ts`, and resolves it at compile time.
```ts title="vela.config.ts"
import { defineConfig } from "vela-rbxts";
import { facetTheme } from "@facet-ui/theme";
export default defineConfig({
theme: {
extend: {
...facetTheme({ base: "zinc", mode: "dark" }),
},
},
});
```
`facet init` writes exactly this. `facetTheme()` returns a plain object — a `colors` map and a
`radius` scale — that you spread into `theme.extend`.
> **extend, not colors**
>
> Vela's `theme.colors` **replaces** the family set, which would strip the ramps (`zinc-500`,
> `red-400`, …) you reach for outside Facet components. `theme.extend` adds to them. That is why
> `facetTheme()` is documented as something you spread into `extend` and not as a `colors` value.
## The tokens
Nineteen, and every Facet component names only these:
| Role | Pair |
| --- | --- |
| Page | `background` · `foreground` |
| Raised surface | `card` · `card-foreground` |
| Floating surface | `popover` · `popover-foreground` |
| Emphasis | `primary` · `primary-foreground` |
| Second emphasis | `secondary` · `secondary-foreground` |
| Recessive | `muted` · `muted-foreground` |
| Hover / highlight | `accent` · `accent-foreground` |
| Danger | `destructive` · `destructive-foreground` |
| Lines | `border` · `input` · `ring` |
Nothing in the registry says `zinc-800`. That is the whole mechanism: because components name roles,
`base: "slate"` rethemes every copied component without editing one of them.
`facet doctor` checks this from the other side — it collects the tokens the installed components
declare and verifies they resolve in your config, so a missing one is reported by name instead of
surfacing later as a Vela diagnostic on a file you never wrote.
## Bases
Four neutral ramps, each a full 50–950 scale: **`zinc`** (default), **`slate`**, **`stone`**,
**`neutral`**. `destructive` comes from a red that is not part of the ramp — `rgb(239, 68, 68)` in
light, `rgb(220, 38, 38)` in dark.
The mapping is mechanical. In dark mode:
```
background ramp[950] primary ramp[50]
foreground ramp[50] primary-foreground ramp[900]
card ramp[900] secondary ramp[800]
muted ramp[800] muted-foreground ramp[400]
border ramp[700] input ramp[700] ring ramp[300]
```
`border` and `input` sit one step lighter than the dark surfaces they are drawn on. At `ramp[800]`
an outline button was almost indistinguishable from a ghost one, which is a rendered-in-Studio
finding rather than a taste call.
Light mode inverts it: `background` and `card` are white, `foreground` is `ramp[950]`, `primary` is
`ramp[900]`, `border` and `input` are `ramp[200]`.
## Radius
```ts
facetTheme({ radius: "new UDim(0, 12)" })
```
Sets what `rounded-*`'s `DEFAULT` resolves to. Defaults to `new UDim(0, 8)`. The value is a string
because it is a Luau expression that Vela emits verbatim, not a number it interprets.
## Overriding
`facetTheme()` returns a plain object, so overriding is spreading:
```ts title="vela.config.ts"
import { defineConfig } from "vela-rbxts";
import { facetTheme } from "@facet-ui/theme";
const base = facetTheme({ base: "slate", mode: "dark" });
export default defineConfig({
theme: {
extend: {
...base,
colors: {
...base.colors,
primary: "Color3.fromRGB(88, 101, 242)",
"primary-foreground": "Color3.fromRGB(255, 255, 255)",
},
},
},
});
```
Spread `base.colors` before your overrides, not after — dropping it replaces the whole map and every
token you did not restate stops resolving.
Colour values are Luau expressions as strings. `Color3.fromRGB(…)`, `Color3.fromHex("#5865F2")`,
anything Vela will emit.
## One mode per build
> **mode is a build-time choice, not a switch**
>
> Vela resolves `className` at compile time: `bg-primary` becomes a literal `Color3` in the emitted
> Luau. A build therefore carries exactly one theme, and `facetTheme({ mode: "dark" })` is a decision
> made by the compiler, not by the running game.
>
> There is no `useTheme()` and no way to flip a settings toggle to light mode with the components as
> they are.
>
> The previews on this site are built the same way, and show it: flipping the docs' own light/dark
> toggle **reloads the frame against a different build** rather than repainting the one that is
> running, because there is nothing in a running Facet tree that a theme signal could reach.
This is a real gap and it is [an open decision](https://docs.astra-void.xyz/facet/getting-started/scope-and-status.md#decisions-that-are-open),
not an oversight. The three options on the table:
1. **Do nothing.** One mode per build. Simplest, and correct for most games, which pick a look and
keep it. This is where Facet is.
2. **A runtime `ThemeProvider` alongside classes.** A React context supplies `Color3` values;
components read it for the handful of props that need to change and use classes for everything
else. This is the tempting one and probably the trap — it splits the styling story in two, and
the failure mode is a component whose background is themeable and whose border is not, with
nothing in the source explaining which is which.
3. **Vela emits a token indirection.** Lower `bg-primary` to a read from a runtime token table the
consumer can swap, instead of to a literal. This is the honest fix, and it is a Vela change —
which means Facet cannot make it unilaterally.
The leaning is 1 now, 3 eventually. If 3 becomes real, `@facet-ui/theme` is the natural place to
define which tokens are runtime-swappable, and no registry component changes.
**Until then**, if you need a runtime toggle: it belongs in your copy of the components, applied as
instance props rather than classes, the same way `Button` handles its disabled fade. That is the
escape hatch the copy-in model gives you, and taking it is a decision to maintain those components
yourself.
## Retheming without touching a component
```bash title="Try another ramp"
# edit vela.config.ts: base: "zinc" → base: "stone"
npx rbxtsc
```
That is the whole procedure. If changing a ramp requires editing a component, that component named a
ramp step somewhere it should have named a role — see
[Component conventions](https://docs.astra-void.xyz/facet/guides/component-conventions.md#8-roles-never-ramp-steps).
---
# Variants and classes
> fv() recipes, why cn does not merge conflicts, and the one ordering rule the whole model rests on.
Source: https://docs.astra-void.xyz/facet/guides/variants-and-classes/
`@facet-ui/react-variants` is Facet's [`class-variance-authority`](https://cva.style/) equivalent,
and the only runtime package a copied component imports from Facet. Two exports:
- **`fv()`** — build a recipe: a base class string plus variant axes, returning a resolver from a
selection to a flat class string.
- **`cn()`** — flatten `ClassValue`s into a class string.
Full signatures in the [reference](https://docs.astra-void.xyz/facet/reference/variants.md).
## Recipes
```tsx
import { fv, type VariantProps } from "@facet-ui/react-variants";
const buttonVariants = fv("flex-row items-center justify-center w-fit rounded-md", {
variants: {
variant: {
default: "bg-primary hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent",
},
size: {
sm: "h-8 px-3",
md: "h-9 px-4",
},
},
defaultVariants: { variant: "default", size: "md" },
});
type ButtonVariants = VariantProps;
// → { variant?: "default" | "outline" | "ghost"; size?: "sm" | "md" }
buttonVariants({ variant: "outline", size: "sm", className: "w-full" });
```
Resolution order inside a recipe: **base → each matching variant → matching compound variants →
the caller's `className`**. The caller lands last, on purpose. That is the whole story of the next
section.
Recipes hold no colour, size, or font value of their own. They name tokens; Vela resolves those from
your `vela.config.ts` at compile time. That is what makes a copied component themeable without you
editing the component.
### Compound variants
```tsx
const badgeVariants = fv("rounded-full", {
variants: {
variant: { default: "bg-primary", outline: "border border-input" },
size: { sm: "px-2 py-0.5", md: "px-3 py-1" },
},
compoundVariants: [{ variant: "outline", size: "sm", className: "px-1.5" }],
defaultVariants: { variant: "default", size: "md" },
});
```
Matched against the *resolved* selection — defaults included — so a compound fires whether the axis
came from the caller or from `defaultVariants`.
## `cn` does not resolve conflicts
shadcn's `cn` is `clsx` + `twMerge`. Facet's is only the flattener, and the reason `twMerge` exists
does not survive the port.
**On the web**, class order in the attribute means nothing. Tailwind emits utilities in a canonical
stylesheet order and the later *rule* wins, so `p-4 p-2` is 1rem of padding and so is `p-2 p-4`.
`tailwind-merge` exists to make attribute order mean what everyone assumes it already means — without
it, a consumer's `className` cannot reliably override a recipe.
**In Vela**, tokens resolve left to right into instance properties and a later token overwrites an
earlier one. Class order *is* specificity. `fv()` already appends the caller's `className` last, so
within one class list a later token wins by construction — the behaviour `twMerge` simulates is
native.
("The caller" here means whatever code calls the recipe, which is the component itself. A `className`
passed in from *outside* the component is a different story — see
[Overriding from the call site](#overriding-from-the-call-site).)
The cross-family cases agree too:
```
px-4 p-2 → p-2 wins on both axes (twMerge drops px-4; same result)
p-2 px-4 → x from px-4, y from p-2 (twMerge keeps both; same result)
w-fit w-9 → w-9 (same family, later wins)
```
A merge pass would cost a token table tracking every Vela family, kept in sync with a compiler that
is still adding families, to reproduce an outcome that is already correct.
## The one rule
> **Nothing may be appended after the consumer's className**
>
> This is the entire price of not having a merge pass, and it is a rule you inherit the moment you
> edit a copied component.
State-derived classes go through the recipe's `className` slot, **ahead** of the consumer's:
```tsx
// wrong — the consumer cannot override the disabled look
cn(buttonVariants({ variant, size, className: props.className }), disabled && "bg-muted")
// right
buttonVariants({ variant, size, className: cn(disabled && "bg-muted", props.className) })
```
`button` had this backwards until it was written down. The failure is quiet: a consumer passes
`className="bg-red-500"`, nothing happens, and there is nothing in either file explaining why.
The same rule is why `TextSlot` takes no `className` — see
[Text and labels](https://docs.astra-void.xyz/facet/guides/text-and-labels.md#textslot-takes-no-classname).
### When to revisit this
If Vela ever stops resolving last-token-wins, or grows a mechanism that lets a recipe's tokens land
after the consumer's, this breaks silently — a consumer override that simply does nothing. It is
worth re-reading whenever Vela's resolution order changes.
## Overriding from the call site
> **A className passed to a Facet component does nothing**
>
> This is measured, not predicted: a ` ` rendered
> in the docs' own preview gallery comes out `bg-primary` — white on the default dark theme, exactly
> like a ` ` with no `className` at all. The class is dropped in silence, with no diagnostic
> on either side.
Two Vela behaviors compose into it, and neither is wrong on its own:
**1. `className` on a component is consumed at the call site.** Vela lowers it where it is written,
so the component never receives a string:
```tsx
// emitted:
```
`props.className` inside `Label` is `undefined`. The recipe slot it would have been threaded into
never sees it. (This is the same mechanism that makes
[`TextSlot` take no `className`](https://docs.astra-void.xyz/facet/guides/text-and-labels.md#textslot-takes-no-classname) —
there it is load-bearing, here it is in the way.)
**2. A component's own class-derived props are emitted after its spreads.** Whatever the author
wrote, Vela moves the resolution to the end of the prop list:
```tsx
// as authored in label.tsx
// as emitted
```
So the `TextColor3` from step 1 arrives through `passthrough` and is then overwritten by the
component's own recipe. Both halves of the override lose.
### What that means for a copied component
**The answer is the model's answer: edit the file.** That is what a copied component is for, and it
is the only override that reliably works. `variant` and `size` cover the cases the component
anticipated; anything else is a change to source you own.
What still crosses the boundary is any instance property the component's classes do not set —
`LayoutOrder`, `Position`, `Visible`, `ZIndex`, event handlers. Those arrive through passthrough and
survive, because nothing overwrites them.
**That includes the properties a call-site `className` resolved to**, which is what makes this
confusing rather than merely inconvenient: the class list is not dropped as a unit, it is dropped
property by property.
```tsx
```
`rounded-md` and `border-border` land — the root recipe names no corner and no stroke. `h-32` does
not: the recipe says `h-full`, and it resolves last. Two thirds of the class list works, which reads
as "the class worked" right up until the size is wrong.
### Why the `className` prop exists at all
It is not decoration. Inside the component it is a real slot that a *sibling* recipe or a wrapper in
the same file threads through, and `fv()`'s ordering discipline is what makes that composition
correct. It is also what a copied component uses on itself once you edit it. It just cannot be
driven from outside by a Vela-compiled caller.
Whether Facet should keep advertising it as a public prop, drop it, or push for a Vela change that
hands components their `className` as a string is
[open](https://docs.astra-void.xyz/facet/getting-started/scope-and-status.md#decisions-that-are-open).
## `ClassValue` is a reserved name
Vela inlines its runtime into every file it transforms, and that runtime declares a local
`ClassValue`. A component importing that name gets:
```
TS2440: Import declaration conflicts with local declaration of 'ClassValue'.
```
`~/lib/utils` re-exports it as **`ClassName`** for this reason. Use that:
```tsx
import { type ClassName, cn } from "~/lib/utils";
```
## Dynamic classes are a different code path
Every Facet class string comes out of `fv()`, which makes it a computed expression — and Vela
resolves computed `className` at **runtime**, not at compile time. For a long time that runtime path
implemented a strict subset of the static lowering, and the missing families did nothing at all
rather than erroring:
| Vela version | What the computed path gained |
| --- | --- |
| 0.7.0 | `flex-*`, `items-*`, `justify-*`, `fit`/`auto`, `text-`, `text-`, `font-` |
| 0.8.0 | `opacity-*`, `whitespace-*`, `leading-*` — the last holdouts |
| 0.9.0 | no new families; scoped the inlined runtime so `card` compiles at all |
That history is why the first published `button` shipped zero pixels wide and every label sat on
Roblox's 8px default. No component carries a workaround for a missing family any more, and the CLI's
`^0.9.0` floor keeps you above the whole list.
> **When a class does nothing, suspect the path before the class**
>
> The rule that outlives the specific gaps. Check what the emitted runtime actually resolves:
>
> ```bash
> grep -o 'startsWith(token, "[a-z0-9-]*")' out/shared/ui/button.luau | sort -u
> ```
>
> Whatever is missing there is missing at runtime, whatever the static path or the docs say.
---
# Text and labels
> Why text is an uppercase prop, what TextSlot does, and the two rules every text recipe obeys.
Source: https://docs.astra-void.xyz/facet/guides/text-and-labels/
## `Save ` does not compile
```tsx
Save
// TS2747: 'Button' components don't accept text as child elements.
```
roblox-ts React's `ReactNode` is `ReactElement | ReactFragment | ReactPortal | boolean | undefined`
— no string member, because host instances draw text from a `Text` property rather than from a text
node. And TypeScript intersects a component's `children` with `React.Attributes["children"]`, so
widening the component's own type does not help either. It is `TS2747` either way.
This is not a Facet decision. It is the shape of the platform, and the only question left is how to
spell the escape hatch and how consistently.
## The answer: `Text?: string`
```tsx
```
Three rules make it consistent:
1. **`Text?: string` on every component that draws a string itself.** Always listed in that
component's `OWN_PROPS`, so it never reaches the host instance by accident.
2. **`children` keeps shadcn's meaning** — composition. An icon, a nested element, anything that is
not a bare string.
3. **Two text regions means two components, never two props.** shadcn splits `CardTitle` /
`CardDescription`; Facet follows. A component that grows a second string prop — `Text` plus
`Description` — is a component that should have been split.
### Why uppercase, when every other Facet prop is lowercase
`variant`, `size`, `asChild`, `disabled` are lowercase because they belong to Facet. Uppercase is
the Roblox namespace, and `Text` belongs to it: it is already a member of
`PassthroughProps` and `PassthroughProps`.
> **The name to take is the name you would have to defend against**
>
> Declare the prop as lowercase `text` and the name `Text` stays live on the passthrough path. A
> Roblox developer writes ` ` — the obvious thing to write — it lands on the host
> instance, and Roblox's 8px near-black default is drawn *underneath* the styled label, with nothing
> in the source explaining why.
>
> Declaring `Text` shadows that and intercepts it. This is also what retired `TextSlot`'s lowercase
> `text` prop: it wrote `Text={props.text}` *before* spreading passthrough, so a stray `Text` silently
> won the very label the component was trying to style.
This is not a claim that `Text` is the nicer API. It is the only one the compiler allows, made
uniform. If roblox-ts React ever accepts string children, `TextSlot` already prefers `children` when
`Text` is absent — components would keep working while the registry moved over.
## `TextSlot`
```bash
npx facet-rbxts add text # usually arrives as a dependency of button or badge
```
```tsx
export function TextSlot(props: TextSlotProps) {
if (props.Text === undefined) {
return <>{props.children}>;
}
return (
(props, OWN_PROPS)}
/>
);
}
```
Given `Text` it draws the styled label; otherwise it renders `children`. That is the whole component.
The text is a **child instance**, not the parent's own `Text` property, and that is what lets the
label be sized and coloured independently of the button around it, and sit beside an icon:
```tsx
```
`size-fit` on that label is not decoration. A label with no resolved size collapses its parent's
automatic sizing along with it — a `w-fit` button around an unmeasured label is a zero-pixel button.
### `TextSlot` takes no `className`
> **Vela lowers className at the call site**
>
> `` becomes a **runtime host**, and the resolved
> `TextColor3` / `TextSize` / `FontFace` arrive inside `TextSlot` as ordinary props. `TextSlot` only
> has to forward them onto its `textlabel`.
>
> Accepting a `className` prop and re-applying it inside would put `TextSlot` *after* the call site in
> resolution order and drop those props on the floor instead — which is exactly what put every button
> label on Roblox's 8px near-black default.
>
> The same trap waits for any component that wraps another component and expects to re-read
> `className` from its own props.
## Every text recipe declares a `font-*`
The rule that costs the most to learn by accident.
Vela leaves `FontFace` untouched when no `font-*` token appears, and Roblox's untouched default is
**LegacyArial**. That is not a weight of the font every other label resolves to — it is a different
typeface, visibly larger at the same `TextSize`.
`card`'s description had no `font-*`. It rendered in Arial next to a SourceSansPro title, inside the
same header, for as long as nobody had opened it in Studio. Reading the emitted Luau did not catch
it, because the emitted Luau was correct — it simply never set `FontFace`.
**Weight is not optional styling here. It is the only thing that says which font.**
## A label needs its own recipe
Nothing inherits. `text-sm` on a button does not reach the label inside it, because text properties
belong to the instance that draws the text.
```tsx
export const buttonVariants = fv("… w-fit rounded-md"); // the button
export const buttonLabelVariants = fv("font-medium", { … }); // the text
```
Both key off the same `variant` and `size` props. This is the single biggest structural difference
from shadcn, where one class list on the parent styles everything under it — and it is why every
Facet component that draws a label exports two recipes rather than one.
## Where a class cannot express it at all
There is exactly one case in the current registry, and it is worth knowing because you will hit it
the moment you write a component that fades:
`opacity-*` composes into everything the compiler can see under an element — but not into a
*component* child, whose instances are created elsewhere. And a class on `TextSlot` resolves against
a tag the runtime cannot identify, so it drops the text-only half.
So a disabled `Button` states the label's fade as an instance prop:
```tsx
```
`props.children` are unreachable for the same reason. A component that renders children onto a faded
surface has to state it there too.
---
# 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
```
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 & { 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 (props, OWN_PROPS);
return (
{props.children}
);
}
```
## 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({
value: props.checked,
defaultValue: props.defaultChecked ?? false,
onChange: props.onCheckedChange,
});
```
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
// dropped, silently
```
[`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.
---
# Updating copied components
> There is no facet upgrade. What diff and remove can tell you, what they deliberately cannot, and why there is no lock file.
Source: https://docs.astra-void.xyz/facet/guides/updating-copied-components/
The copy-in model means Facet can never push an update. A component in your project is a file in
your repository; nothing reaches back into it. What the CLI offers instead is visibility — and it is
careful about how much it claims.
## `facet diff`
```bash
npx facet-rbxts diff # every installed component
npx facet-rbxts diff button # one
```
Compares each copied file against what the registry would write there **today**, replaying the `~/`
rewrite first so your project's alias and directory choices are not reported as changes.
The direction is registry → project: `-` is what the registry has, `+` is what your file says. That
way applying the `+` side is what "keep mine" means, and a diff of your own edits reads the way
`git diff` would have shown you making them.
> **It cannot tell your edit from an upstream change**
>
> Facet records nothing at copy time — no lock file, no hashes, no note of which registry version a
> file came from. So a difference here is your edit, a change upstream, or both, and `diff` says so in
> its own output rather than implying the change came from upstream.
>
> Your version control already knows which lines you wrote. That is the tool for the other half of
> the question.
A component in the registry that is not in your project is reported as such and skipped. A component
you never added produces nothing — that is not news.
## Taking an upstream change
There is no command for this, and that is the model working as intended:
1. `facet diff button` — see what moved.
2. Decide. Most upstream changes to a component you have edited are not worth taking.
3. If you want it: `facet add button --overwrite` replaces the file wholesale, and your edits are
gone. Commit first, then reapply what you want from the diff.
`--overwrite` is deliberately blunt. There is no three-way merge because there is no base text to
merge against — see below.
## `facet remove`
```bash
npx facet-rbxts remove badge
npx facet-rbxts remove badge --force
```
Two refusals, and they are not the same kind of refusal.
**A file that differs from the registry** is refused without `--force`. "Differs" is the closest
available stand-in for "you changed this", and it also fires when only upstream moved — so in the
worst case you are asked about a file you never touched. That is the safe direction to be wrong in:
asking too often is recoverable, deleting someone's edited component is not.
**A component another installed component still imports** is refused, and `--force` does not cover
it. Deleting `utils` out from under an installed `button` leaves a project that does not compile,
and you asked to remove one component, not to break another.
That second check runs to a fixed point, which matters more than it sounds:
```bash
npx facet-rbxts remove button utils
```
If `button` turns out to be modified and so stays, `utils` is suddenly still imported — and a single
pass would already have cleared it. The plan is recomputed until nothing changes.
Directories are left alone, and so is anything you added next to the removed files.
## Why there is no lock file
A `facet.lock` of per-file content hashes is the obvious fix for what `diff` cannot say, and it was
the plan. Three reasons it was dropped:
**A hash answers *whether*, and a diff has to show *how*.** Showing what a merge would involve needs
the text you started from — three-way, base included. So the record that would actually satisfy
`facet diff` is not a hash but a second copy of every component, committed to your repository and
kept in step with the first. That is vendoring the registry into the project the CLI just copied out
of, to serve one command.
**Even the yes/no is weaker than it looks.** The hash goes stale for reasons that are not edits: a
project-wide formatter pass over `src/shared/ui`, a rename, a move to another directory. Facet would
then report that you changed everything — true, useless, and indistinguishable from the report it
would give if upstream had rewritten every component.
**It is a file you commit, never edit, and cannot read.** The promise of the model is that a copied
file is yours. A lock file is Facet keeping a ledger about files it gave away, and the first time it
disagrees with reality — and it will — it is a puzzle in your repository that Facet put there.
## Pinning a registry revision
The default registry **moves**: `https://facet.astra-void.xyz/r` is republished on every push to
Facet's `main`, so an edit to a component reaches everyone immediately — including a project that ran
`facet add` months ago and will next run `facet diff`.
Every push also writes an immutable copy under the commit that produced it, and a project can point
at one instead:
```json title="facet.json"
{ "registry": "https://facet.astra-void.xyz/r/a1b2c3d" }
```
[`revisions.json`](https://facet.astra-void.xyz/revisions.json) lists what exists, newest first.
`add`, `diff`, `list` and `doctor` all read the field and all print which registry they used.
**What pinning fixes:** `facet diff` stops reporting upstream movement you did not ask for. Against
a frozen revision, a difference is your edit or nothing — which is most of what
[the lock file](#why-there-is-no-lock-file) was supposed to buy, without a lock file.
**What it does not fix:** it is per-project, not per-component. Pinning says "this project builds
against that registry", not "`button` came from that revision and `card` from this one". And a
pinned project stops receiving fixes until someone moves the pin, which is a decision somebody has
to remember to make.
> **It still cannot attribute a change**
>
> Pinning removes upstream movement as a *source* of drift; it does not record what your file started
> as. Unpin, or move the pin forward, and `diff` is back to reporting a difference it cannot explain.
> The record that would settle it is still a second copy of every component —
> [which is why there is no lock file](#why-there-is-no-lock-file).
The thing revisions genuinely unlock is that the base text becomes something the CLI could one day
*fetch* rather than something you store. That is not implemented, and it is the reason the versioning
scheme was worth building the way it was.
## What to actually do
- **Commit the copied files.** They are source, not build output.
- **Do not expect updates.** If a component is a starting point you rewrote, `diff` will be noisy
forever and that is fine — stop running it on that component.
- **Run `facet doctor` after upgrading anything.** It is the check that catches a project set up by
an older CLI: the copied files are current and the versions under them are not.
---
# Using another registry
> Point the CLI at a fork, a private registry, or a local build — the four-step resolution order and what a registry has to serve.
Source: https://docs.astra-void.xyz/facet/guides/custom-registry/
The CLI fetches components at runtime rather than bundling them, which means the source of those
components is a setting. Four ways to change it, in resolution order — most specific first.
## Resolution order
| # | Source | Set by | Use for |
| --- | --- | --- | --- |
| 1 | `FACET_REGISTRY_DIR` | env, a local **directory** | Testing a registry build without publishing it |
| 2 | `--registry `, or `registry` in `facet.json` | flag or config | Forks, private registries, version pins |
| 3 | `FACET_REGISTRY_URL` | env, a URL | CI, or a machine-wide default |
| 4 | `https://facet.astra-void.xyz/r` | built in | The published registry |
A value starting with `http://` or `https://` is read as a URL; anything else is resolved as a
filesystem path.
```bash title="Each of the first three"
FACET_REGISTRY_DIR=site/r npx facet-rbxts list
npx facet-rbxts add button --registry https://ui.example.com/r
FACET_REGISTRY_URL=https://ui.example.com/r npx facet-rbxts list
```
```json title="facet.json — persistent, and committed with the project"
{
"registry": "https://ui.example.com/r"
}
```
The config field is the right one for a team: everyone who clones the project gets the same
registry without remembering a flag.
> **The default URL is baked into every released CLI**
>
> `https://facet.astra-void.xyz/r` is a constant in the published package, which is why the Facet
> repo's own deploy generates a `CNAME` file as part of the site artifact — deploying from a GitHub
> Actions artifact replaces the whole site, and without it GitHub can revert the custom domain to the
> `github.io` default. That outage is not something a patch release fixes quickly.
## What a registry has to serve
Three kinds of file over plain HTTP (or a directory with the same layout):
```
/index.json the index — every item, its files, dependencies, and tokens
/button.json one payload per item, source text inlined
/utils.json
```
The CLI reads `index.json` first and refuses anything whose `version` is not `1`, telling the user
to upgrade rather than guessing at a format it does not know. Item payloads are fetched lazily —
only for what is actually being added, removed, or diffed.
A 404 on an item is treated as a user typo; a 404 on the index means the registry moved. Requests
carry `accept: application/json` and time out after 15 seconds.
The full shape is in the [registry format reference](https://docs.astra-void.xyz/facet/reference/registry-format.md).
## Running a fork
The Facet repo builds its registry with one script, and a fork inherits it:
```bash title="In a fork of astra-void/facet"
pnpm registry:check # structural validation — names, types, dependency resolution
pnpm registry:build # registry/ → site/
```
`site/` is generated and never committed, so the published registry cannot drift from `registry/`.
The build emits the index, one JSON per item, an immutable `r//` copy of both, a landing page
listing what exists, a `CNAME`, a `.nojekyll` (Pages runs Jekyll otherwise, which eats
underscore-prefixed paths), `schema.json` — the JSON Schema every `facet.json` names in its own
`$schema` — and `revisions.json`, listing which frozen revisions exist.
`schema.json` is generated from the `FacetConfig` type rather than checked in, so it cannot be a
version behind the CLI that writes the files pointing at it. It is typed to cover every key of that
type, so a new config field does not compile until it is described.
> **Revisions need somewhere to accumulate**
>
> Pages deploys one artifact and the artifact **replaces the entire site**, so a revision written by
> one deploy would be deleted by the next. Facet's published tree therefore lives on a `gh-pages`
> branch that accumulates: the workflow checks it out, copies the new build over it, commits, and
> uploads *that* tree as the artifact.
>
> The branch is storage, not a deployment source — Pages is still deployed from Actions, and `.git` is
> removed before the upload so the accumulator's history is not part of the site. A fork that does not
> care about revisions can skip all of this and upload `site/` directly.
Test against the working tree before publishing anything:
```bash title="The CLI, against a local build"
FACET_REGISTRY_DIR=site/r npx facet-rbxts list
FACET_REGISTRY_DIR=site/r npx facet-rbxts add button --cwd ../some-project
```
> **Three places name the host**
>
> `DEFAULT_REGISTRY_URL` in the CLI's registry source, `$schema` in its config module, and
> `CUSTOM_DOMAIN` in the build script. They must agree — a mismatch between the last one and the first
> two means the CLI asks a host the site no longer claims.
## Adding your own components to a fork
Three edits, and it is not done until all three are made:
1. **The source** in `registry/src/ui` (or `lib`, `hooks`), addressing other registry files with
`~/` — never a relative path, because the CLI rewrites `~/` and cannot rewrite `../lib/utils`.
2. **An entry** in `registry/registry.ts` declaring every import as a `dependency` (npm) or a
`registryDependency` (another item), plus the semantic `tokens` its classes name so `facet
doctor` can check them.
3. **A scene** in `apps/playground` — the only place anyone sees it render.
`registry:check` enforces the structural half: unique names, known item types, resolvable registry
dependencies, no file claimed by two items, and **one spec per npm package across the whole
registry**. That last one exists because `facet add` unions dependency strings across the install
set, so `@lattice-ui/react-runtime` and `@lattice-ui/react-runtime@^0.8.0` in two entries would
survive as two entries and both reach the package manager.
What it cannot check is a missing npm dependency — an import the entry never declared. That is on
the author, and it ships a file that cannot compile in a project that did not happen to have the
package already.
## A private registry
Nothing about the format requires GitHub Pages. Any static host works, and so does a directory on
disk. If it is behind auth, the CLI has no credential support — point `FACET_REGISTRY_DIR` at a
checkout your build process already has access to.
---
# Troubleshooting
> The failures that produce no error — unstyled UI, zero-pixel components, labels in the wrong font — and the ones that do.
Source: https://docs.astra-void.xyz/facet/guides/troubleshooting/
Start here:
```bash
npx facet-rbxts doctor
```
It checks the config, the import aliases, the transformer, the theme, which components are
installed, whether the tokens they name resolve, and whether the packages underneath them meet the
floors those files need. Most of what follows is something `doctor` would have told you.
## Nothing is styled at all
Everything renders as grey Roblox defaults. No error, no warning, and the build succeeded.
**The Vela transformer is not registered.** Without it, every `className` a component sets is inert.
```json title="tsconfig.json"
{
"compilerOptions": {
"plugins": [{ "transform": "vela-rbxts/transformer" }]
}
}
```
`facet init` and `facet doctor` both check for this textually and neither edits the file for you —
roblox-ts tsconfigs are routinely JSONC, and a pattern-matched edit that mangles one is worse than a
printed snippet.
## A component renders zero pixels wide
**One axis has no answer.** `UIPadding` does not grow a frame on Roblox — it insets children — and
Vela starts a frame at `UDim2.new(0, 0, 0, 0)`.
```
h-9 px-4 → 0 × 36. Invisible.
h-9 w-fit px-4 → correct.
```
If the component is a container, check the whole chain rather than the outermost level: a container
that hugs its content can only measure children that already know their own size, so one label with
an unresolved axis collapses everything above it.
## A class compiles but does nothing
**Suspect the code path before the class.** Every Facet class string comes out of `fv()`, so it is a
computed expression, and Vela resolves those at runtime rather than at compile time. That runtime
path implemented a strict subset of the static lowering for a long time:
| Below Vela | These silently do nothing |
| --- | --- |
| 0.7.0 | `flex-*`, `items-*`, `justify-*`, `fit`/`auto`, `text-`, `text-`, `font-` |
| 0.8.0 | `opacity-*`, `whitespace-*`, `leading-*` |
Check what the emitted runtime actually resolves:
```bash
grep -o 'startsWith(token, "[a-z0-9-]*")' out/shared/ui/button.luau | sort -u
```
Whatever is missing there is missing at runtime, whatever the static path says. The CLI's
`vela-rbxts@^0.9.0` floor keeps you above the whole list — `facet doctor` is what verifies you are
actually on it.
## `Out of local registers`
```
Out of local registers when trying to allocate CardHeader
```
Pointing at generated code nobody wrote. **You are on Vela 0.8.x.** That release inlined Vela's
runtime into every transformed file, spending roughly 96 of Luau's 200 local registers before the
file declared anything of its own — and `card` does not fit in what is left.
Upgrade to `vela-rbxts@^0.9.0`, which scopes the runtime into one initializer; emitted files drop
from ~106 module-scope locals to ~24.
If you hit this on a component you wrote, the same limit is the cause: each exported name costs a
register. `card` exports one `cardVariants` object rather than six separate recipes for exactly this
reason.
## One label is in a different font
Larger, and visibly not the typeface everything else uses. **That text recipe has no `font-*`
token.** Vela leaves `FontFace` untouched when none appears, and Roblox's untouched default is
LegacyArial.
Add a weight — `font-normal` counts. This is not optional styling; it is the only thing that says
which font. `card`'s description shipped this way until someone opened Studio.
## A button's label is 8px and near-black
Two causes, both about `className` arriving at the wrong place.
**You are using `asChild`.** `TextSlot` never renders on that path, so the label recipe is not
applied and the child draws its own text at Roblox's default. Style the child yourself —
`buttonLabelVariants` is exported for it. See [Button](https://docs.astra-void.xyz/facet/components/button.md#aschild).
**Or a wrapper re-applied `className` internally.** Vela lowers `className` at the *call site*, so
`` becomes a runtime host whose resolved `TextColor3` / `TextSize` /
`FontFace` arrive as ordinary props. A component that accepts a `className` prop and re-applies it
inside drops those props instead. `TextSlot` takes no `className` for this reason.
## The dialog throws the first time it opens
```
[PortalProvider] context is undefined. Wrap components with .
```
**There is no `PortalProvider` above your app.** `Dialog.Portal` reads the `PlayerGui` it renders
into from a strict context. Everything before this point succeeds — it compiles, it type-checks, it
ships — and the failure lands on a player pressing the button.
The `.Provider` in the message is Lattice's generic strict-context wording; the component to reach
for is `PortalProvider` itself.
```tsx title="src/client/main.client.tsx"
import { PortalProvider } from "@lattice-ui/react-layer";
root.render(
,
);
```
One wrapper for the whole app, not one per dialog. `facet add dialog` offers to write it for you —
[what it does and when it refuses](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider) — and `facet doctor`
reports it missing.
## `border-b` boxes my element instead of underlining it
**A one-sided border is not a thing Roblox can draw.** `border-*` lowers to a `UIStroke`, which
outlines the whole instance, so Vela treats every side-specific key — `border-b`, `border-t`,
`border-x` and their prefixed forms — as
[unsupported and drops it](https://docs.astra-void.xyz/vela-rbxts/guides/colors-and-surfaces.md#borders), silently. What is left
is `border-border`, which colours a stroke that then draws on all four sides.
Reach for `divide-x` / `divide-y` on the parent instead: Vela lowers those to real one-pixel frames
interleaved between its children, so a stack gets a rule between each pair and none after the last.
Where you need a single rule in a specific place, put a
[`Separator`](https://docs.astra-void.xyz/facet/components/separator.md) in the flow by hand.
The registry's `accordion` is the worked example — it carries `divide-y divide-border` on its root
for exactly this reason. See
[Accordion](https://docs.astra-void.xyz/facet/components/accordion.md#the-rule-between-items-lives-on-the-root).
## A component ignores the size I gave it
**The recipe names that property, so it wins.** This is the
[call-site `className`](#a-classname-i-pass-to-a-component-is-ignored) problem in its most confusing
form, because *some* of the class list survives:
```tsx
```
`rounded-md` and `border-border` reach the instance — the root recipe names neither a corner nor a
stroke. `h-32` does not: the recipe says `h-full`, and a component's class-derived props are emitted
after its spreads. So the box takes its parent's height while looking like the class worked.
Put the size on a wrapper frame, or edit the copied file.
## A `className` I pass to a component is ignored
**Expected — it cannot work.** ` ` renders exactly like
` `, silently. Vela consumes the class at the call site and lowers it into instance props,
and the component's own class-derived props are emitted after its spreads, so they overwrite what
arrived. Both halves of the override lose.
The answer is the one the copy-in model is built on: **edit the copied file**. Full explanation, and
what does still cross the boundary, in
[Overriding from the call site](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
## A `className` inside a component I edited is ignored
Different problem, and this one is a bug you can fix. **Something is appended after
`props.className`.** Vela resolves last-token-wins, so a class landing after it is an override
nothing can undo:
```tsx
// wrong
cn(buttonVariants({ variant, className: props.className }), disabled && "bg-muted")
// right
buttonVariants({ variant, className: cn(disabled && "bg-muted", props.className) })
```
`cn` deliberately does not resolve conflicts —
[why](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#cn-does-not-resolve-conflicts).
## `TS2747` on a string child
```tsx
Save // TS2747
// this
```
roblox-ts React's `ReactNode` has no string member. Not a Facet choice —
[the whole story](https://docs.astra-void.xyz/facet/guides/text-and-labels.md).
## `TS2440: Import declaration conflicts with local declaration`
On `ClassValue`. Vela inlines a runtime that declares a local by that name. Import
**`ClassName`** from `~/lib/utils`, which re-exports it under a name that does not collide.
## `facet add` writes nothing
```
Everything requested is already here. Pass --overwrite to replace it.
```
`add` never overwrites by default. `--overwrite` replaces wholesale — commit first, your edits are
gone.
## `facet remove` refuses
**"differs from the registry"** — the file is not what the registry would write there today. That
means you edited it, *or* upstream moved; Facet records nothing at copy time and cannot tell them
apart. `--force` deletes it anyway.
**"is still imported by …"** — another installed component depends on it. `--force` does not cover
this one, because deleting `utils` out from under `button` leaves a project that does not compile.
Remove the dependents first.
## Registry errors
| Message | Means |
| --- | --- |
| `Could not reach the registry at …` | Network, or a wrong `registry` value. `add` needs the network; the registry is fetched, not bundled. |
| `… is format version N, which this CLI does not understand` | Upgrade `facet-rbxts`. |
| `Unknown component "x"` | Run `facet list`. |
| `Component "x" was not found at …` | A registry serving an index that disagrees with its own files — a fork's build problem. |
## `No package.json found above …`
`facet` walks up from the current directory to the nearest `package.json` and treats it as the
project root. Run it inside your roblox-ts project, or pass `--cwd`.
## Tokens do not resolve
```
tokens 4 token(s) installed components name are not defined: card, card-foreground, …
```
Your `vela.config.ts` does not spread `facetTheme`. `doctor` reads the config as text when it does
not — naming a token as a colour key counts — because a Vela config is arbitrary TypeScript and the
alternative to reading it is evaluating it.
The fix is in [Theming](https://docs.astra-void.xyz/facet/guides/theming.md). If a token is reported as not being in
`@facet-ui/theme` either, that is a Facet bug rather than a project one — upgrade the theme package
or report it.
## Imports do not resolve after copying
If `facet.json` sets an `import` specifier on an alias, your tsconfig needs a matching `paths`
entry; `doctor` fails on this rather than warning, because every copied component imports through
it. Either add the entry, or clear `"import"` in `facet.json` and let the CLI write relative imports
— which need no `paths` and work anywhere.
Changing that setting does not rewrite files already copied. `facet add --overwrite` rewrites
them with the new resolution.
> **Studio is still the only place some of this shows up**
>
> Compiling is a static result. A component can compile clean, emit exactly the properties you expect,
> and still look wrong — that is how `card`'s description shipped in Arial and how the first `button`
> shipped zero pixels wide. If a component is behaving strangely and nothing above explains it, open
> Studio and look at the instance tree.
---
# CLI
> Every command and flag of facet-rbxts, and what each one checks before it writes.
Source: https://docs.astra-void.xyz/facet/reference/cli/
The package installs as **`facet-rbxts`** and the command is **`facet`**. Node 20+, ESM.
```bash
npx facet-rbxts [options]
```
| Command | What it does |
| --- | --- |
| [`init`](#init) | Writes `facet.json`, creates `vela.config.ts` if absent, installs build dependencies |
| [`add`](#add) | Copies components in, with their registry dependencies |
| [`list`](#list) | Shows every component in the registry |
| [`remove`](#remove) | Deletes copied components |
| [`diff`](#diff) | Shows how a copied component differs from the registry |
| [`doctor`](#doctor) | Checks the project matches what components assume |
## Global options
| Option | |
| --- | --- |
| `--cwd ` | Run against another directory. The project root is the nearest `package.json` at or above it. |
| `--registry ` | Read from another registry — see [Using another registry](https://docs.astra-void.xyz/facet/guides/custom-registry.md) |
| `--version`, `-v` | |
| `--help`, `-h` | |
Every command resolves the project root by walking up from `--cwd` (or the current directory) to the
nearest `package.json`, and fails with `No package.json found above …` if there is none.
## `init`
```bash
npx facet-rbxts init [-y] [--force] [--no-deps]
```
| Option | |
| --- | --- |
| `--yes`, `-y` | Accept every default instead of prompting |
| `--force` | Overwrite an existing `facet.json` |
| `--no-deps` | Skip every package install |
Prompts for the theme base (`zinc` / `slate` / `stone` / `neutral`), the mode (`dark` / `light`),
the three directories, and an optional import alias — leaving the alias blank means relative
imports, which need no tsconfig `paths`.
Then, in order:
1. Writes `facet.json`.
2. Creates `vela.config.ts` pre-wired with `facetTheme()` **only if it does not exist**. An existing
one is never rewritten — if it does not use `facetTheme`, the exact lines to add are printed.
3. Installs `@facet-ui/theme` and `vela-rbxts@^0.9.0` as dev dependencies.
4. Runs `add utils`, because every component imports `~/lib/utils`. This is also what installs
`@facet-ui/react-variants`.
5. Checks `tsconfig.json` for `vela-rbxts/transformer` and prints the snippet if it is absent.
Refuses to run if `facet.json` already exists, unless `--force`.
## `add`
```bash
npx facet-rbxts add [--overwrite] [--dry-run] [--no-deps] [--yes]
```
| Option | |
| --- | --- |
| `--overwrite` | Replace files that already exist instead of skipping them |
| `--dry-run` | Resolve and report, write nothing |
| `--no-deps` | Skip the package install |
| `--yes` | Answer the provider-wiring prompt with yes instead of asking |
Resolves the named items plus their `registryDependencies`, transitively and in dependency order.
For each file: rewrites `~/` imports for where the file will land, then writes it. All writes go
through one transaction that commits or rolls back as a unit.
Files that already exist are **skipped**, not overwritten, and reported as `exists`. `--overwrite`
replaces them wholesale — there is no merge.
The union of the install set's npm `dependencies` is then installed, through whichever package
manager your lockfile implies (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb`/`bun.lock` →
bun, `package-lock.json` → npm; then the `packageManager` field; then npm).
Finally, if `vela.config.ts` does not use `facetTheme` and the added components name tokens, it says
so with the token list — rather than letting the next build be the messenger with a Vela diagnostic
on a file you never wrote.
> **Lockfile over the packageManager field**
>
> The field states intent; the lockfile states what the project actually installed with. Installing
> with the wrong one leaves two lockfiles disagreeing.
### Wiring a provider
If an added component declares a [`providers`](https://docs.astra-void.xyz/facet/reference/registry-format.md#providers) entry —
today that is [`dialog`](https://docs.astra-void.xyz/facet/components/dialog.md) and its `PortalProvider` — `add` offers to wrap
your client entry in it. This is the **one file the CLI edits that it did not write**, and the bar
for doing so is deliberately high.
```
PortalProvider has to wrap your app — Lattice reads the portal target from it,
and throws when a dialog opens without one
? Add it to src/client/main.client.tsx? (Y/n)
```
Why this and nothing else: every other thing the CLI reports is a **build-time** failure. A missing
transformer means every class is inert on the next `rbxtsc`. A missing token is a Vela diagnostic.
Both are loud, and both land in front of the person who just ran the command. A missing
`PortalProvider` compiles, type-checks, passes CI, ships — and throws the first time a player opens
the dialog, hundreds of lines of package-manager output after the snippet would have been printed.
**The parser is used for positions, never for output.** `@babel/parser` answers where the last
import ends and where the render call's argument starts; the edit itself is two string splices.
Everything outside those two offsets comes out byte for byte identical — no re-quoted strings, no
moved comments, no re-indented JSX on a file you arranged deliberately. A test asserts the exact
bytes.
**Anything ambiguous is reported, not guessed:**
| What the entry looks like | What happens |
| --- | --- |
| One `.render(` call, a `PlayerGui` expression somewhere | Wrapped, after the prompt |
| Two files under `src/` mount a tree | Snippet, naming both |
| Two `.render(` calls in the one entry | Snippet — which tree to wrap is a design question |
| No `PlayerGui` named anywhere | Snippet — synthesizing the lookup means editing imports too |
| The entry does not parse | Snippet |
| Provider already imported | Nothing, and it says so |
The `PlayerGui` case is the one worth explaining. Writing
`Players.LocalPlayer.WaitForChild("PlayerGui")` into the file also means adding `Players` to an
`@rbxts/services` import that may or may not exist — a second, riskier edit for a situation that
barely occurs, since a client that mounts React always names its `PlayerGui` already. So the CLI
reuses the expression that is there and stops when there is none.
> **Not being asked is not the same as saying no**
>
> The prompt defaults to yes and is the last thing `add` does, so nothing scrolls past it. `--yes`
> writes without asking.
>
> A **non-interactive** run without `--yes` writes nothing and prints the snippet, reported as
> *"nothing here to ask, and this is not a file to edit unasked"*. stdin not being a terminal is not
> consent — and it is also not you having declined, which is why the two read differently.
`facet doctor` checks the same thing on every run, so a provider that gets refactored out later is
reported rather than rediscovered at runtime.
Multiple providers are wired one at a time, and the entry is re-read between them — the second one
wraps the file the first just rewrote.
## `list`
```bash
npx facet-rbxts list [--registry ]
```
Every item in the registry with its description, alphabetically, plus which registry was read.
**Inside a project it reads that project's `registry` field**, so a project pinned to a revision or
pointed at a fork is shown what it can actually add. **Outside one it falls back to the default** —
a missing or unreadable `facet.json` is an ordinary outcome here, not an error, because listing what
exists is what you run *before* `init`.
> **This was wrong until 0.3.1**
>
> `list` was the one command that never opened `facet.json`, so a pinned project was still shown the
> moving registry — offering components an `add` in that project could not fetch. Found by pinning a
> real project to a published revision and watching `list` report the registry underneath it.
## `remove`
```bash
npx facet-rbxts remove [--force]
```
| Option | |
| --- | --- |
| `--force` | Delete a file that differs from the registry |
Removes exactly what you name. Dependencies are **not** pulled in — that would delete `utils`
because you removed `button`.
Two refusals:
- **A file that differs from the registry**, compared after replaying the same `~/` rewrite `add`
applied on the way in, so a file that was only ever copied reads as untouched. `--force` overrides
this.
- **A component another installed component still imports.** `--force` does **not** override this
one. The check runs to a fixed point: if one item stays behind because it was modified, anything it
imports stops being free to delete.
Directories are left alone, and so is anything you added next to the removed files.
## `diff`
```bash
npx facet-rbxts diff [name]
```
With no argument, walks everything the registry knows about that also exists in the project.
Compares each file against what the registry would write there today, replaying the `~/` rewrite
first so your alias and directory choices are not reported as changes. Direction is registry →
project: `-` is the registry, `+` is your file.
Facet records nothing at copy time, so this **cannot tell your edit from an upstream change** — and
it says so in its own output rather than implying otherwise. See
[Updating copied components](https://docs.astra-void.xyz/facet/guides/updating-copied-components.md).
## `doctor`
```bash
npx facet-rbxts doctor
```
Eight checks, in order. Each is `ok`, `warn`, or `fail`; any `fail` exits non-zero.
| Check | Fails when |
| --- | --- |
| **`facet.json`** | never — reports style, theme, and whether each alias directory exists |
| **import alias** | `facet.json` sets an `import` and tsconfig declares no `paths` entry that could resolve it |
| **transformer** | `tsconfig.json` does not register `vela-rbxts/transformer` |
| **theme** | `vela.config.ts` does not exist (a config that exists but does not use `facetTheme` is a warning) |
| **components** | never — lists what is installed; warns when an item is missing some of its files |
| **tokens** | an installed component names a token the theme does not define |
| **packages** | a package is missing, or older than the floor the copied files need |
| **providers** | an installed component declares a provider and the client entry is not wrapped in it |
The providers check is the only one whose failure is not a build failure. A
[`dialog`](https://docs.astra-void.xyz/facet/components/dialog.md) with no `PortalProvider` above it compiles, ships, and throws
the first time a player opens it — and nothing in the copied file can prevent that, because the
wiring lives in a file Facet does not own. When it fails it prints the snippet; when it cannot find
a single client entry to look at, it *warns* rather than failing, and says to check by hand.
The packages check is the one that catches a project set up by an older CLI: the copied files are
current and the versions under them are not. It reads the version actually sitting in
`node_modules` — walking up, because a project inside a workspace has its dependencies hoisted —
rather than the declared range, since the two differ exactly when someone installed once and never
again.
A registry it cannot reach is a **gap in the report**, not the end of it: the first four checks
stand on their own, and the last three are skipped with a warning saying so.
> **Half an item is not a missing item**
>
> An item counts as present the moment one of its files does. Deleting a copied file is allowed — it
> is your file — so a half-installed item is reported, not treated as absent. Its tokens and packages
> are still needed by whatever is left.
### Version comparison is deliberately not semver
The comparison answers one question — *is what is installed at least the version this needs?* — for
the shapes Facet actually writes (`^0.9.0`, `>=0.8.0`, `0.1.1`). Anything it cannot read is reported
as **unverifiable** rather than guessed at, because a doctor that invents a failure is worse than one
that admits a gap.
## Environment variables
| Variable | |
| --- | --- |
| `FACET_REGISTRY_DIR` | A local directory. Wins over everything, including `--registry`. |
| `FACET_REGISTRY_URL` | A URL. Loses to `--registry` and to `facet.json`. |
## Exit codes
`0` on success. `1` on a user-facing error — an unknown command, an unreachable registry, a failed
`doctor`. Anything else is a bug and throws with a stack.
---
# facet.json
> Every field of the project config — what it controls, what the default is, and why.
Source: https://docs.astra-void.xyz/facet/reference/facet-json/
Written by `facet init` at the project root, committed with the project, and read by every command
except `list`.
```json title="facet.json — the defaults"
{
"$schema": "https://facet.astra-void.xyz/schema.json",
"style": "default",
"theme": { "base": "zinc", "mode": "dark" },
"aliases": {
"ui": { "dir": "src/shared/ui" },
"lib": { "dir": "src/shared/lib" },
"hooks": { "dir": "src/shared/hooks" }
},
"velaConfig": "vela.config.ts"
}
```
The file is parsed loosely and normalized against those defaults, so a hand-edited config missing a
key picks the default up rather than crashing mid-copy.
## `$schema`
The JSON Schema the file advertises, for editor completion and validation. It is **generated** from
the CLI's `FacetConfig` type by the same deploy that publishes the registry, so it can never be a
version behind the CLI that writes files pointing at it.
## `style`
`"default"`, and there is no second value.
shadcn shipped two styles and has since deprecated `default`; the field outlived the second style.
That is most of the argument, and it is worse here: nothing about a Roblox component is verifiable
by reading it, so a second style doubles the set of components that have to be opened in Studio and
looked at — which is already the expensive, manual step.
The field stays because `facet.json` is a file you commit, and removing a key from it is a breaking
change for the benefit of deleting one line. It is also the natural place for a fork to say what it
serves. Nothing in the CLI branches on it, and it is typed as the literal `"default"` rather than
`string`, so any code that starts branching has to change the type first.
## `theme`
```json
"theme": { "base": "zinc", "mode": "dark" }
```
| Field | Values | |
| --- | --- | --- |
| `base` | `"zinc"` · `"slate"` · `"stone"` · `"neutral"` | Neutral ramp the semantic tokens derive from |
| `mode` | `"dark"` · `"light"` | Which side of the ramp they resolve to |
These are what `init` writes into the generated `vela.config.ts`, and what a later `doctor` uses to
print the correct `facetTheme({ … })` snippet if the config is unwired.
> **Editing this does not retheme anything on its own**
>
> The theme lives in `vela.config.ts`. Changing `facet.json` after `init` changes what the CLI would
> *suggest*, not what your build resolves — `init` never rewrites an existing Vela config. Change both,
> or change the Vela config alone. See [Theming](https://docs.astra-void.xyz/facet/guides/theming.md).
## `aliases`
Where each class of file lands, and how other copied files import it.
```json
"aliases": {
"ui": { "dir": "src/shared/ui", "import": "shared/ui" },
"lib": { "dir": "src/shared/lib", "import": "shared/lib" },
"hooks": { "dir": "src/shared/hooks", "import": "shared/hooks" }
}
```
| Field | |
| --- | --- |
| `dir` | Directory from the project root. A **real path**, because roblox-ts projects are laid out by Rojo, not by module resolution. |
| `import` | The specifier other copied files use to reach this directory. **Omit for relative imports.** |
Which alias a file lands under is decided by its registry item type:
| Item type | Alias |
| --- | --- |
| `registry:ui` | `ui` |
| `registry:lib` | `lib` |
| `registry:hook` | `hooks` |
| `registry:block` | `ui` |
**Relative is the default**, because it needs no tsconfig `paths` and therefore works in a roblox-ts
project nobody configured for this. With `import` set, the rewrite is textual —
`~/lib/utils` → `shared/lib/utils`. Without it, the CLI computes a relative path from the importing
file's destination, so `~/lib/utils` inside `src/shared/ui/button.tsx` becomes `../lib/utils`.
If you set `import`, your tsconfig needs a matching `paths` entry. `facet doctor` **fails** on this
rather than warning, because every copied component imports through it. It accepts the specifier
itself or any wildcard that would cover it — `"shared/ui"`, `"shared/ui/*"` and `"shared/*"` all
count.
> **Changing an alias does not move files already copied**
>
> It only affects what the next `add` writes. To re-resolve an existing component, re-add it with
> `--overwrite` — and commit first, because that replaces the file wholesale.
## `velaConfig`
```json
"velaConfig": "vela.config.ts"
```
Path to the project's Vela config, from the project root. `init` creates it if it does not exist;
nothing ever rewrites it. `add` and `doctor` read it to check whether Facet's tokens are supplied.
That check is textual — it looks for `@facet-ui/theme` in the source. A config that spells the
tokens out by hand instead of spreading `facetTheme` is *unwired* but not necessarily broken, and
`doctor` then looks for each required token by name as a colour key. That is the most that can be
checked without evaluating a file that is arbitrary TypeScript.
## `registry`
```json
"registry": "https://ui.example.com/r"
```
Optional. A URL or a filesystem path. Omit for the published registry at
`https://facet.astra-void.xyz/r`.
This is the right place to point at a fork or a private registry for a team — everyone who clones the
project gets the same source without remembering a `--registry` flag. It is overridden by
`--registry` on the command line and by `FACET_REGISTRY_DIR` in the environment. See
[Using another registry](https://docs.astra-void.xyz/facet/guides/custom-registry.md).
### Pinning a revision
`https://facet.astra-void.xyz/r` **moves** — every push to Facet's `main` republishes it. The same
push also writes an immutable copy under the commit that produced it, and this field is how you pin
one:
```json
{ "registry": "https://facet.astra-void.xyz/r/a1b2c3d" }
```
Every command reads it — `add` copies from that revision, `diff` compares against it, `list` shows
what it holds, `doctor` checks it — and each prints which registry it used.
[`revisions.json`](https://facet.astra-void.xyz/revisions.json) lists what exists.
> **No new field, no new flag**
>
> This is deliberate, and it is the whole reason the versioning scheme is shaped this way. `registry`
> has been in `facet.json` since `init` first wrote one, for forks and private registries — so a CLI
> released long before revisions existed can pin one today. Nothing about the format changed either;
> a pinned revision is an ordinary registry base that happens never to move.
Pinning trades currency for stability, and the trade is real in both directions: a pinned project
gets a registry that cannot shift under it, and stops receiving fixes until someone changes this
line. See [Updating copied components](https://docs.astra-void.xyz/facet/guides/updating-copied-components.md#pinning-a-registry-revision)
for when that is worth doing.
---
# @facet-ui/theme
> facetTheme(), the nineteen tokens, and the exact ramp step each one resolves to.
Source: https://docs.astra-void.xyz/facet/reference/theme/
`@facet-ui/theme` · Stable direction · import `facetTheme`
Facet's semantic tokens, shaped as a Vela config preset. A Node package — only `vela.config.ts`
imports it, so it is a dev dependency and never reaches the Roblox runtime.
Installed by `facet init`. Currently 0.4.0, versioned in lockstep with the CLI and
`@facet-ui/react-variants`.
## `facetTheme(options?)`
```ts
import { facetTheme } from "@facet-ui/theme";
facetTheme({ base: "zinc", mode: "dark", radius: "new UDim(0, 8)" });
```
Returns a `ThemeExtend` — a plain object with `colors` and `radius` — for spreading into
`theme.extend`:
```ts title="vela.config.ts"
export default defineConfig({
theme: { extend: { ...facetTheme({ base: "zinc", mode: "dark" }) } },
});
```
`extend` rather than `colors`, because Vela's `theme.colors` *replaces* the family set — which would
strip the ramps (`zinc-500`, `red-400`, …) you reach for outside Facet components.
| Option | Type | Default | |
| --- | --- | --- | --- |
| `base` | `"zinc" \| "slate" \| "stone" \| "neutral"` | `"zinc"` | Neutral ramp the tokens derive from |
| `mode` | `"light" \| "dark"` | `"dark"` | Which side of the ramp they resolve to |
| `radius` | `string` | `"new UDim(0, 8)"` | What `rounded-*`'s `DEFAULT` resolves to |
`radius` is a string because it is a Luau expression Vela emits verbatim, not a number it interprets.
## Other exports
| Export | |
| --- | --- |
| `FACET_TOKENS` | The nineteen token names as a `const` tuple. This is what `facet doctor` checks a registry item's declared tokens against. |
| `FacetToken` | Union of those names. |
| `buildTokens(options?)` | The token → colour-expression map, without the `radius` wrapper. |
| `buildColors(options?)` | Same map, typed as Vela's `ColorInputMap`. |
| `RAMPS`, `Ramp` | The four neutral ramps, 50 through 950. |
| `DESTRUCTIVE`, `WHITE` | The red that is not part of any ramp, and white. |
| `FacetBase`, `FacetMode`, `FacetThemeOptions` | Option types. |
| `ThemeExtend`, `ThemeScale`, `ColorExpression`, `ColorInputMap`, `ColorPalette` | Structural mirrors of the slice of Vela's config surface this package writes into. |
Those Vela-shaped types are kept local rather than imported so that `@facet-ui/theme` type-checks
without resolving `vela-rbxts`. They are checked against Vela in the CLI's `doctor` command instead.
## The tokens
```
background foreground
card card-foreground
popover popover-foreground
primary primary-foreground
secondary secondary-foreground
muted muted-foreground
accent accent-foreground
destructive destructive-foreground
border input ring
```
Every Facet component names only these. Nothing in the registry says `zinc-800`, which is what lets
`base: "slate"` retheme a project without touching a single copied-in component.
## What each token resolves to
### Dark
| Token | Ramp step | | Token | Ramp step |
| --- | --- | --- | --- | --- |
| `background` | `950` | | `primary` | `50` |
| `foreground` | `50` | | `primary-foreground` | `900` |
| `card` | `900` | | `secondary` | `800` |
| `card-foreground` | `50` | | `secondary-foreground` | `50` |
| `popover` | `900` | | `muted` | `800` |
| `popover-foreground` | `50` | | `muted-foreground` | `400` |
| `accent` | `800` | | `border` | `700` |
| `accent-foreground` | `50` | | `input` | `700` |
| `destructive` | `rgb(220, 38, 38)` | | `ring` | `300` |
| `destructive-foreground` | `50` | | | |
`border` and `input` sit one step lighter than the dark surfaces they are drawn on. At `800` an
outline button was almost indistinguishable from a ghost one — a rendered-in-Studio finding, not a
taste call.
### Light
| Token | Value | | Token | Value |
| --- | --- | --- | --- | --- |
| `background` | white | | `primary` | `900` |
| `foreground` | `950` | | `primary-foreground` | `50` |
| `card` | white | | `secondary` | `100` |
| `card-foreground` | `950` | | `secondary-foreground` | `900` |
| `popover` | white | | `muted` | `100` |
| `popover-foreground` | `950` | | `muted-foreground` | `500` |
| `accent` | `100` | | `border` | `200` |
| `accent-foreground` | `900` | | `input` | `200` |
| `destructive` | `rgb(239, 68, 68)` | | `ring` | `950` |
| `destructive-foreground` | `50` | | | |
## The ramps
Standard Tailwind neutrals, as Luau `Color3.fromRGB(…)` expressions:
| | 50 | 500 | 950 |
| --- | --- | --- | --- |
| `zinc` | `250, 250, 250` | `113, 113, 122` | `9, 9, 11` |
| `slate` | `248, 250, 252` | `100, 116, 139` | `2, 6, 23` |
| `stone` | `250, 250, 249` | `120, 113, 108` | `12, 10, 9` |
| `neutral` | `250, 250, 250` | `115, 115, 115` | `10, 10, 10` |
Each is a full 50/100/200/300/400/500/600/700/800/900/950 scale; only the ends and midpoint are
shown here.
## One mode per build
Vela resolves classes at compile time, so `mode` is a build-time choice and a build carries exactly
one theme. There is no runtime toggle. What the alternatives would cost is in
[Theming](https://docs.astra-void.xyz/facet/guides/theming.md#one-mode-per-build).
---
# @facet-ui/react-variants
> fv(), cn(), and the types — including the two roblox-ts quirks the implementation exists to work around.
Source: https://docs.astra-void.xyz/facet/reference/variants/
`@facet-ui/react-variants` · Stable direction · import `fv, cn`
The one runtime package a Facet component imports from Facet. An rbxts package, built by `rbxtsc` —
everything else a component needs is either a Lattice primitive or a Vela class string.
Installed by `facet add` as a dependency of the `utils` registry item. Currently 0.4.0.
## `fv(base, config?)`
Facet variants. The `cva`-shaped recipe builder.
```tsx
import { fv, type VariantProps } from "@facet-ui/react-variants";
const buttonVariants = fv("flex-row items-center w-fit rounded-md font-medium", {
variants: {
variant: {
default: "bg-primary hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
},
size: { sm: "h-8 px-3", md: "h-9 px-4" },
},
compoundVariants: [{ variant: "outline", size: "sm", className: "px-2.5" }],
defaultVariants: { variant: "default", size: "md" },
});
buttonVariants({ variant: "outline", size: "sm", className: "w-full" });
```
| Parameter | Type | |
| --- | --- | --- |
| `base` | `ClassValue` | Classes every selection gets |
| `config.variants` | `Record>` | Axis → option → classes |
| `config.defaultVariants` | `VariantSelection` | Applied when the caller omits an axis |
| `config.compoundVariants` | `(VariantSelection & { className: ClassValue })[]` | Extra classes when several axes match at once |
Returns `(selection?) => string`. The selection is one optional key per axis, plus a `className`
slot.
**Resolution order:** base → each matching variant → matching compound variants → the caller's
`className`. The caller lands last so a consumer override wins by construction — which is why `cn`
does not need to merge conflicts, and why nothing in a component may be appended after
`props.className`. See [the one rule](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#the-one-rule).
Compound variants match against the **resolved** selection, defaults included, so one fires whether
the axis came from the caller or from `defaultVariants`.
## `VariantProps`
```tsx
type ButtonVariants = VariantProps;
// → { variant?: "default" | "outline"; size?: "sm" | "md" }
```
Extracts a recipe's selection type, so a component's props can be declared from its recipe rather
than restated beside it:
```tsx
export type ButtonProps = VariantProps & {
Text?: string;
disabled?: boolean;
};
```
## `cn(...inputs)`
```tsx
cn("h-9", disabled && "opacity-50", { "bg-muted": isMuted }, ["gap-2", "px-4"]);
// → "h-9 opacity-50 bg-muted gap-2 px-4"
```
Flattens `ClassValue`s into a space-separated class string. Strings pass through, numbers are
stringified, arrays recurse, records contribute their keys where the value is `true`, and everything
else — booleans, `nil` — is dropped.
> **cn does not resolve conflicting utilities**
>
> Unlike `tailwind-merge`, which of `p-2 p-4` wins is Vela's call, not this package's — Vela resolves
> left to right and a later token overwrites an earlier one, so class order *is* specificity. A merge
> pass would cost a token table tracking every Vela family, kept in sync with a compiler still adding
> families, to reproduce an outcome that is already correct.
> [The full argument](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#cn-does-not-resolve-conflicts).
## Types
| Type | |
| --- | --- |
| `ClassValue` | `string \| number \| boolean \| null \| undefined \| ClassDictionary \| ClassValue[]` |
| `ClassDictionary` | `Record` |
| `ClassItem` | `ClassValue` minus `null` and `undefined` — what may sit inside an array *this package* builds |
| `VariantShape`, `VariantSelection`, `VariantConfig`, `CompoundVariant` | Recipe types |
`ClassValue` **mirrors Vela's exactly**, and exactness matters rather than similarity: Vela augments
`React.Attributes` with `className`, and TypeScript intersects `React.Attributes` into every
component's props — so `props.className` inside a Facet component carries Vela's type no matter what
the component declared. A narrower mirror simply fails to accept it.
> **Import it as ClassName, not ClassValue**
>
> Vela inlines its runtime into every file it transforms, and that runtime declares a local
> `ClassValue`. A component importing that name gets `TS2440: Import declaration conflicts with local
> declaration`. `~/lib/utils` re-exports it as **`ClassName`** for this reason.
## Two roblox-ts quirks in the implementation
Worth knowing if you edit this package or write something like it.
**Lua tables cannot hold `nil` without leaving a hole.** roblox-ts therefore rejects `undefined` and
`null` as array element types — they are the same value at runtime. Accepting them on the way in is
fine; they are just never stored, which is what `ClassItem` exists to express.
**Arrays and records lower onto the same Lua table type**, so the first key's type is what tells
them apart at runtime:
```tsx
const firstKey = next(value as unknown as UnknownTable)[0];
if (typeIs(firstKey, "number")) { /* array */ } else { /* record */ }
```
There is also a type-level trap in `fv`'s signature: the `Variants` default is `Record`
rather than `VariantShape`, because mapping over `VariantShape`'s string keys produces an index
signature of `string | undefined`, which then collides with the `className` slot on the selection
object. Without that default, `fv("…")` with no variants fails to type-check at every call site.
---
# Registry format
> The published JSON contract — index, item payloads, and the validation rules an authored registry has to pass.
Source: https://docs.astra-void.xyz/facet/reference/registry-format/
This is a **published format**. `facet add` consumes it, forks emit it, and changing it
incompatibly means bumping `RegistryIndex.version`.
## What a registry serves
```
/index.json the index the CLI reads first
/.json one payload per item, source text inlined
```
Plus, at the site root rather than under the registry base, two generated files:
| Path | |
| --- | --- |
| `schema.json` | The JSON Schema every `facet.json` names in its own `$schema` |
| `revisions.json` | Which immutable revisions exist, and which one the moving registry mirrors |
Both are generated by the deploy that publishes the registry rather than committed, so neither can
be a version behind the CLI that writes files pointing at them.
The CLI reads `index.json` first and item payloads lazily, only for what is being added, removed, or
diffed. Remote reads send `accept: application/json` and time out after 15 seconds.
## Revisions
The published registry is served twice — once at a path that moves, and once per push at a path that
never will:
```
r/index.json the moving registry every CLI reads by default
r/button.json
r/a1b2c3d/index.json the same bytes, frozen, forever
r/a1b2c3d/button.json
```
A revision is keyed by the **commit SHA** that produced it, because that is the only identifier
already immutable, already unique per publish, and already meaningful outside the registry. A CLI
version would not work: three components shipped under `0.3.0` on separate pushes, since a component
reaches users by being published to the registry rather than by a CLI release.
The two copies are byte-for-byte identical, not re-rendered — an `add` from a pin has to produce what
`add` produced at the time — and a test asserts they match.
`revisions.json` at the site root lists what exists, newest first:
```json
{
"latest": "d345608",
"revisions": [
{ "revision": "d345608", "generatedBy": "0.4.0", "components": 23 },
{ "revision": "821604a", "generatedBy": "0.3.1", "components": 10 }
]
}
```
It is rebuilt by scanning the published tree rather than appended to, so it cannot claim a revision
that is not there. `latest` is the revision `r/` currently mirrors.
> **A revision is forever**
>
> Deleting one breaks every project pinned to it, and unlike a bad npm release there is no version to
> move past. Publishing a broken registry is guarded by `registry:check` before the build — this
> raises the cost of getting it wrong from "fix it on the next push" to "fix it on the next push, and
> the bad revision stays".
To read from one, point at it as a base — see [pinning a revision](https://docs.astra-void.xyz/facet/reference/facet-json.md#pinning-a-revision).
## `index.json`
```json
{
"version": 1,
"generatedBy": "0.4.0",
"items": [
{
"name": "button",
"type": "registry:ui",
"description": "Button with variant and size recipes",
"files": ["ui/button.tsx"],
"registryDependencies": ["utils", "text"],
"dependencies": [
"@facet-ui/react-variants@^0.1.1",
"@lattice-ui/react-runtime@^0.8.0"
],
"tokens": ["primary", "primary-foreground", "…"]
}
]
}
```
| Field | |
| --- | --- |
| `version` | Registry format version. `1` today. A CLI that meets a higher one refuses and says to upgrade, rather than guessing at a format it does not know. |
| `generatedBy` | Version of the `facet-rbxts` release that produced the index. Informational. |
| `items` | Every item, with its files as **bare strings**. |
## Item fields
| Field | Type | |
| --- | --- | --- |
| `name` | `string` | Unique across the registry. What you type after `facet add`. |
| `type` | `registry:ui` · `registry:lib` · `registry:hook` · `registry:block` | Decides which `facet.json` alias the files land under |
| `description` | `string?` | Shown by `facet list` |
| `files` | see below | |
| `dependencies` | `string[]?` | npm packages the source imports, as `name` or `name@range` |
| `devDependencies` | `string[]?` | Same, installed as dev |
| `registryDependencies` | `string[]?` | Other **items** — never files. Resolved transitively by `add` |
| `tokens` | `string[]?` | Semantic theme tokens the classes name, so `doctor` can check them against the consumer's Vela theme |
| `providers` | see below | React providers the component needs above the app |
### `providers`
New in 0.4.0, and the reason it exists is that one failure in the registry is not a build failure.
```json
{
"name": "dialog",
"providers": [
{
"name": "PortalProvider",
"package": "@lattice-ui/react-layer",
"props": { "container": "player-gui" },
"reason": "Lattice reads the portal target from it, and throws when a dialog opens without one"
}
]
}
```
| Field | |
| --- | --- |
| `name` | The component to import and wrap with |
| `package` | Where to import it from |
| `props` | Prop name → a **symbolic** value the tool resolves, not source text |
| `reason` | Printed to the consumer, in both the prompt and `doctor`'s output |
`facet add` [offers to write it into the client entry](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider), and
`facet doctor` reports when it is gone.
> **The container value is a symbol, not a snippet**
>
> The expression for the local player's `PlayerGui` is knowledge about roblox-ts, and it belongs in the
> tool that reads the entry file — which can see what the entry already names — rather than in a string
> the registry hands over to be pasted somewhere it cannot see.
**The registry declares it, not the CLI.** The alternative was for the CLI to know that `dialog`
needs `PortalProvider`. That knowledge would then live in a released binary, and the whole point of
the hosted registry is that a component reaches users by being *published* rather than by a CLI
release — so the next layered component would need a CLI release to be wired correctly.
**`version` did not move for it.** `loadIndex` *rejects* an index whose version it does not
recognise, so bumping to `2` would break every CLI already installed in order to deliver a field
those CLIs would ignore anyway. An optional field is compatible in both directions: an older CLI
does not read `providers`, and a newer one reads `undefined` from an older registry. The number is
for removing a field, renaming one, or changing what an existing one means.
### Item types and where they land
| Type | `facet.json` alias | Default directory |
| --- | --- | --- |
| `registry:ui` | `ui` | `src/shared/ui` |
| `registry:lib` | `lib` | `src/shared/lib` |
| `registry:hook` | `hooks` | `src/shared/hooks` |
| `registry:block` | `ui` | `src/shared/ui` |
## Item payloads
`.json` is the index entry with the file list expanded from strings into objects carrying the
source text:
```json
{
"name": "utils",
"type": "registry:lib",
"files": [
{
"path": "lib/utils.ts",
"type": "registry:lib",
"content": "export { type ClassValue as ClassName, cn } from \"@facet-ui/react-variants\";\n…"
}
],
"dependencies": ["@facet-ui/react-variants@^0.1.1"]
}
```
| File field | |
| --- | --- |
| `path` | Path under the registry's `src`, e.g. `ui/button.tsx`. The **first segment names the alias**. |
| `type` | Per-file item type, which can differ from the item's own |
| `target` | Overrides the alias-derived destination entirely. Rare; blocks use it |
| `content` | The source text, inlined |
A path's first segment is the alias and the rest is kept, so a multi-file block stays a directory
rather than collapsing into the alias root.
> **target does not survive into the index**
>
> The index carries files as bare strings, so neither the per-file `type` nor a `target` override is
> visible there — the first segment is read as the type instead, falling back to the item's own.
>
> A file that overrides its destination with `target` therefore cannot be located from the index at
> all. Only `facet doctor` reads files that way, and it reports what it could not find rather than
> pretending it looked.
## What `~/` means in the content
Registry sources address each other with a `~/` prefix, and the first segment is an alias name:
```tsx
import { TextSlot } from "~/lib/text";
import { cn } from "~/lib/utils";
```
`facet add` rewrites these for the consumer's project — textually when the alias has an `import`
specifier, otherwise as a relative path computed from where the importing file lands. An unknown
alias segment is left alone deliberately, so it fails loudly at typecheck rather than being silently
rewritten to something wrong.
**Relative paths in registry source are a bug.** The CLI can rewrite `~/lib/utils`; it cannot
rewrite `../lib/utils`.
## What a component may import
Anything else will not resolve once copied:
- `@rbxts/*`
- `@lattice-ui/*`
- `@facet-ui/react-variants`
- `~/…`
## Validation rules
`validateRegistry()` runs in `registry:check`, so a broken registry fails CI rather than `facet add`:
| Rule | |
| --- | --- |
| Unique `name` | |
| Known `type` | |
| At least one file per item | |
| No file claimed by two items | |
| Every `registryDependencies` entry resolves | |
| **One spec per npm package across the whole registry** | |
That last one is the non-obvious one. `facet add` **unions** dependency strings across the install
set, so `@lattice-ui/react-runtime` in one item and `@lattice-ui/react-runtime@^0.8.0` in another
survive as two entries and both reach the package manager. A dependency carries a floor when the
source needs behavior a specific version introduced, and then the same package carries the *same*
spec in every entry.
What validation **cannot** catch is a missing npm dependency — an import the entry never declared.
That is on the author, and it ships a file that cannot compile in a project that did not happen to
have the package already.
## Specs
Dependency strings are `name` or `name@range`. The CLI splits on the **last** `@` so a scoped
package's leading one survives: `@facet-ui/theme` is a name, `vela-rbxts@^0.9.0` is a name and a
range, `@lattice-ui/react-runtime@^0.8.0` is both at once.
Range comparison is deliberately not a semver implementation. It answers one question — is what is
installed at least the version this needs — for the shapes Facet writes (`^0.9.0`, `>=0.8.0`,
`0.1.1`), and reports anything else as unverifiable rather than guessing.
## Errors
| Message | Cause |
| --- | --- |
| `The registry at … is format version N, which this CLI does not understand` | `version` above `1` |
| `Could not reach the registry at …` | Network, or a wrong base |
| `The registry returned N for …` | Non-404 HTTP failure |
| `The registry returned something that is not JSON at …` | A host serving an HTML error page |
| `Component "x" was not found at …` | An index that disagrees with the files beside it |
| `Circular registry dependency: a -> b -> a` | An authoring bug; throws rather than being silently broken |