Facethome

Facet

Copy-in UI components for roblox-ts, composed from Lattice UI and Vela.

facet-rbxts · v0.4.0 · pre-1.0

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, which is a real dependency. Styling comes from Vela, 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 rather than bundling them — so adding a component does not require a CLI release.

The whole model
# One command, and a file appears in your project.
npx facet-rbxts init
npx facet-rbxts add button
# src/shared/ui/button.tsx is now yours — edit it, rename it, delete half of it.
# There is no upgrade that will overwrite it, because there is no upgrade.
Source, not a dependency
The registry is fetched at runtime and copied in. The only Facet code you install is fv() and the theme preset.
Behavior from Lattice, styling from Vela
A Facet component is a Lattice primitive wearing Vela classes. The hard parts stay in packages that are dependencies.
Roles, never ramp steps
Components name bg-primary, never bg-zinc-900. Switching the theme base rethemes every copied file without editing one.

Components

What the registry holds today.

Button
Six variants, four sizes, two recipes — and the one thing asChild does not carry across.
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.
Label
A form label — one recipe, no variants, and the smallest component in the registry.
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.
Slider
Three instances whose geometry is entirely the primitive's — and the one component whose track deliberately has no layout.
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.
Text field
Five parts around a TextBox — and the component where a state has to be stated twice because nothing inherits.
Textarea
The one recipe in the registry that declares no height — because the primitive grows it line by line.
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.
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.
Accordion
Seven recipe entries, two contexts, and a divider that moved to the root to survive Roblox.
Alert
Three parts, one recipe object, and a variant that has to be repeated on every part that draws something.
Avatar
A circle drawn by the wrapper, because the primitive under it renders no instance at all.
Badge
A status pill that hugs its label — four variants, and a lesson in size-fit.
Card
Six flat parts, one shared recipe object, and the Luau register limit that forced it.
Kbd
A key cap — and the one place in the registry where font-* picks a typeface on purpose.
Progress
A track and a fill, where the fill's width is the value — and this file names neither number.
Scroll area
A viewport with a drawn scrollbar — and the component whose height has to come from above it.
Separator
A one-pixel divider — and the only component whose background is the point.
Skeleton
A placeholder block — and the component defined by what it deliberately does not do.
Tabs
A list, some triggers, and switched panels — plus one prop you have to pass or nothing looks selected.

Guides

Own the code you copied.

Reference

Every command, field, and token.

Every preview here is the real component.

The frames on the component pages render the actual registry source, lowered by the actual Vela compiler against the actual @facet-ui/theme tokens — the same file facet add would copy into your project, not a web imitation of it. Flip the site's light/dark toggle and the frame reloads a different build, because Facet resolves its colors at compile time and a build carries exactly one mode.

What a preview is not is Roblox. Loom reimplements Roblox layout; it does not run the engine. Studio is still the only place a component is really confirmed.

Scope and status →