Facetgetting started

How it works

The three layers, the hosted registry, and exactly what facet add does to a file between the registry and your project.

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

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.

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:

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 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 <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, 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 — a React provider it needs above your whole app. Today that is dialog, 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.

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:

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.

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.
  • 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.