# 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 ( // TS2747. Not a Facet choice. ``` 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";