Facetgetting started

How it works

The three layers, the hosted registry, and what `facet add` does to a file.

Three layers, and Facet is the thinnest

LayerOwnsPackage
Lattice UIBehavior — focus, layering, presence, portals, controlled/uncontrolled state, asChild@lattice-ui/react-*
VelaStyling — className lowered to Roblox properties at compile timevela-rbxts
FacetThe opinionated composition of the two, as source you ownthe registry

Do not reimplement in a copied component what Lattice or Vela already owns. A Facet component that grows focus management should be fixed upstream in the Lattice primitive. One that grows a literal Color3 has stopped being themeable.

That division is what makes the copy-in model viable. The file you own is composition: recipes, a host element, prop plumbing. It is short because everything difficult sits in a package that is a dependency.

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 their CLI version.

The cost is that add needs a network — the same trade shadcn/ui makes, and the failure is loud rather than silently stale. You can point elsewhere: see Using another registry.

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, because the CLI can rewrite ~/lib/utils and cannot rewrite ../lib/utils:

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:

No import specifier — the default
import { TextSlot } from "../lib/text";
import { cn } from "../lib/utils";
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. It 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, so it fails loudly at typecheck rather than being rewritten to something wrong.

Nothing else is touched — no formatting, no codemod, no injected header.

Where files land

A registry file’s path is <alias>/<name>, and the item’s type decides the alias:

Item typefacet.json aliasDefault directory
registry:uiuisrc/shared/ui
registry:liblibsrc/shared/lib
registry:hookhookssrc/shared/hooks
registry:blockuisrc/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.

One transaction

Every write goes through a transaction that commits or rolls back as a unit. A failure halfway through facet add card cannot leave 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 — a React provider it needs above your whole app. Today that is dialog, which cannot find a PlayerGui to portal into.

This is the only place the CLI touches a file it did not write, and it asks first. Everything else it reports fails at build time. A missing provider fails at runtime, in production. See wiring a provider.

What the copied component then depends on

A Facet component may import from four places only, enforced by review, because anything else will not resolve once copied:

  • @rbxts/*
  • @lattice-ui/*
  • @facet-ui/react-variants
  • ~/... — other registry files

Each of those 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.

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:

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. facetTheme() maps those roles onto a neutral ramp, and Vela resolves them to literal Color3 values at compile time, so switching base rethemes every copied component.

What Facet does not do

  • It does not record anything at copy time. No lock file, no hashes, no note of which registry version a file came from. That is why facet diff can show that a file differs but not whether you changed it. See Updating copied components.
  • It does not update. There is no facet upgrade. The most the CLI can do is show a diff.
  • It does not have a second style. facet.json keeps shadcn’s style field pinned to "default", and nothing branches on it.