This assumes a roblox-ts project that already builds, with Vela’s transformer registered and
facet init run once — see Installation.
Add it
npx facet-rbxts add button✔ write src/shared/lib/utils.ts✔ write src/shared/lib/text.tsx✔ write src/shared/ui/button.tsxThree 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
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>,);npx rbxtscThe 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" /> // thisroblox-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.
Vela leaves FontFace untouched when no font-* token appears, and Roblox’s untouched default is LegacyArial — a different typeface, visibly larger at the same TextSize. card’s description had no font-* and rendered in Arial beside a SourceSansPro title until someone looked. Weight is not optional styling here. It is the only thing that says which font.
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
asChildcaveat. - Theming — retheme all of it without editing a component.