Facetgetting started

Your first component

Copy a button in, render it, and read the three things that are not the shadcn version.

This assumes a roblox-ts project that already builds, with Vela’s transformer registered and facet init run once — see Installation.

Add it

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 is listed as exists and left alone if init already added it — add never overwrites without --overwrite.

The npm packages the entry declares are installed at the same time, through whichever package manager your lockfile implies. --no-deps skips that. --dry-run resolves and reports without writing.

Render it

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 (
<frame className="h-full w-full flex-col items-center justify-center gap-2 bg-background">
<Button Text="Save" onClick={() => print("saved")} />
<Button variant="outline" size="sm" Text="Cancel" />
</frame>
);
}
const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui");
const root = createRoot(new Instance("Folder"));
root.render(
<StrictMode>
{createPortal(
<screengui ResetOnSpawn={false} IgnoreGuiInset>
<App />
</screengui>,
playerGui,
)}
</StrictMode>,
);
Build
npx rbxtsc

The three things that are not shadcn

If you have written shadcn/ui, two of these will bite you within the hour.

1. Text is a prop, not children

<Button>Save</Button> // TS2747. Not a Facet choice.
<Button Text="Save" /> // this

roblox-ts React’s ReactNode has no string member, because host instances draw text from a Text property rather than a text node. A bare string child is a type error whatever the component declares. children keeps its shadcn meaning: composition.

The prop is spelled uppercase to shadow the Roblox instance property. Text is already a member of PassthroughProps<TextButton>. A lowercase text would leave the obvious <Button Text="Save" /> landing on the host instance, drawing Roblox’s 8px near-black default underneath the styled label. See Text and labels.

2. Both axes, always

UIPadding does not grow a frame on Roblox, it insets children. Vela resolves a frame’s size from UDim2.new(0, 0, 0, 0), so nothing hugs its content unless you ask.

h-9 px-4 → 0 × 36 pixels. Invisible.
h-9 w-fit px-4 → hugs its content. Correct.
h-9 w-9 → fixed square. Also correct.

h-9 px-4 is a good shadcn button and renders nothing here. That is why buttonVariants carries w-fit in its base classes and the icon size overrides it with a concrete w-9. Every class string needs an answer on both axes.

3. Nothing inherits

There is no cascade: text-sm on a button does not reach the label inside it, because text properties belong to the instance that draws the text.

That is why button.tsx exports two recipes:

export const buttonVariants = fv("… w-fit rounded-md", { /* geometry + surface */ });
export const buttonLabelVariants = fv("font-medium", { /* colour + size of the text */ });

Both key off the same variant and size props — the biggest structural difference from shadcn, where one class list on the parent styles everything under it.

Now edit it

That is the point of the model. src/shared/ui/button.tsx is a file in your repository: change the variants, drop the ones you do not use, rename the component. Facet has no mechanism to push an update over it.

Editing is also the only override that reliably works. A className passed in from outside is consumed by Vela at the call site, then overwritten by the component’s own recipe. See Overriding from the call site.

Preserve ordering discipline while you edit: nothing may be appended after props.className inside the component, because Vela’s resolution is last-token-wins. See Variants and classes.

Next step

  • How it works — the registry, the rewrite, the layers.
  • Button — every prop, variant, and the asChild caveat.
  • Theming — retheme all of it without editing a component.