Copy-in UI components for roblox-ts, built on Lattice UI and Vela.
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. There is no upgrade that will overwrite your changes,
because there is no upgrade.
npx facet-rbxts init
npx facet-rbxts add button dialog toast| Layer | Owns | Package |
|---|---|---|
| Lattice UI | behavior — focus, layering, presence, controlled state | @lattice-ui/react-* |
| Vela | styling — className lowered to Roblox props at compile time |
vela-rbxts |
| Facet | the opinionated composition of the two, as source you own | this repo |
A Facet component is a Lattice primitive wearing Vela classes. Nothing more. That is why it can be handed to you as a file rather than a dependency — the hard parts already live in packages that are dependencies.
Two small packages and a CLI. The components are not published; they are copied.
| Package | What it does |
|---|---|
@facet-ui/react-variants |
fv() variant recipes and cn() — the cva equivalent. The only runtime code a Facet component imports from Facet. |
@facet-ui/theme |
Semantic tokens (primary, muted-foreground, …) as a Vela config preset. Facet's answer to :root { --primary }. |
facet-rbxts |
The CLI. Fetches components from the hosted registry rather than bundling them. |
packages/
react/variants/ @facet-ui/react-variants — rbxts runtime, built by rbxtsc
tools/theme/ @facet-ui/theme — node, feeds vela.config.ts
tools/cli/ facet-rbxts — node, the CLI
registry/
registry.ts the authored manifest — what exists, what it depends on
src/ui/*.tsx the component sources `facet add` copies
src/lib/*.ts shared helpers those components import
apps/
playground/ Roblox app that consumes a synced copy, like any project
scripts/
build-registry.ts registry/ → site/, the static registry served to the CLI
sync-playground.ts registry/ → apps/playground/src/shared, doing what `add` will
site/ generated, never committed; deployed to GitHub Pages by CI
registry/src is compiled by nothing at publish time — it is text that gets copied. It is checked
in two places, and both matter:
- the
registryworkspace type-checks the sources in place,~/aliases and all apps/playgroundtype-checks and builds them after a sync that copies and rewrites exactly asfacet addwill, so a rewriting bug surfaces here rather than in someone's project
The playground is a consumer, not a second home for the sources.
registry/ builds to a static site on GitHub Pages, and the CLI fetches from it. Adding a component
means publishing the registry, not releasing the CLI — see
docs/decisions/registry-hosting.md.
facet add button --registry https://example.com/r # a fork or private registry
FACET_REGISTRY_DIR=site/r facet list # a local buildr/ moves — every push to main republishes it. Every push also writes an immutable r/<sha>/
that never changes again, so a project that wants a registry that does not move under it pins one in
facet.json:
{ "registry": "https://facet.astra-void.xyz/r/a1b2c3d" }add, diff, and doctor all read that field, and
revisions.json lists what exists — see
docs/decisions/registry-versioning.md.
Roblox has no CSS variables, so the indirection that makes shadcn/ui themeable lives one layer down,
in 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), never ramp steps (bg-zinc-900). Switching base rethemes
every copied component without touching one of them. Because Vela resolves at compile time, a build carries
exactly one mode — see docs/decisions/runtime-theming.md.
Early, but the whole chain works. All three packages are on npm and the registry is live, so
npm i -D facet-rbxts → facet init → facet add button → rbxtsc compiles in a project set up
from scratch. Every command the CLI advertises — list, init, add, remove, diff, doctor —
is written. The registry holds alert, badge, button, card, kbd, label, separator,
skeleton, and the utils and text helpers they import.
What has not been checked is how any of it looks: compiling is a static result, and Roblox runtime behavior only shows up in Studio. See docs/roadmap.md.
MIT