Facetcomponents

Button

Six variants, four sizes, two recipes — and the one thing asChild does not carry across.

Terminal window
npx facet-rbxts add button

Copies ui/button.tsx, plus lib/utils.ts and lib/text.tsx as registry dependencies. Needs @facet-ui/react-variants and @lattice-ui/react-runtime@^0.8.0.

import { Button } from "../shared/ui/button";
<Button Text="Save" onClick={() => print("saved")} />
<Button variant="outline" size="sm" Text="Cancel" />
<Button variant="destructive" Text="Delete" />
<Button size="icon" Text="✕" />
<Button disabled Text="Unavailable" />
Every variant and size, plus the disabled fade — rendered from the registry source.

Renders a TextButton. Unknown props forward onto it and are type-checked against it, so a prop TextButton does not accept is a compile error. The primitive owns Active and Selectable from the disabled state, so values you pass for those are ignored.

Text, AutoButtonColor, BackgroundTransparency and BorderSizePixel are set as neutral defaults before the passthrough spread, so you can override those — see Neutral defaults. Event is composed rather than replaced, so a handler you pass still fires alongside the component’s own.

Props

PropTypeDescription
TextstringThe label. Drawn as a styled child textlabel, not as this instance's Text — so it can be sized and coloured independently and sit beside an icon.
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link"Surface and label colour. Defaults to default.
size"sm" | "md" | "lg" | "icon"Height, padding, and label size. Defaults to md.
disabledbooleanDims to opacity-50, clears Active and Selectable, and swallows onClick. Not a Vela variant — Facet's own state.
onClick() => voidComposed onto Activated rather than replacing it, so a passthrough Event handler still fires.
asChildbooleanRender the single child element instead of a textbutton, merging the recipe and behavior onto it. Errors if there is no child element.
classNameClassNameThreaded into the recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site.
childrenReact.ReactNodeComposition — an icon, a nested element. Rendered instead of the label when Text is absent.

Everything else is forwarded onto the TextButton and type-checked against it.

Variants

variantSurfaceLabel
defaultbg-primary, hover:bg-primary/90text-primary-foreground
destructivebg-destructive, hover:bg-destructive/90text-destructive-foreground
outlineborder border-input bg-background, hover:bg-accenttext-foreground
secondarybg-secondary, hover:bg-secondary/80text-secondary-foreground
ghostnone, hover:bg-accenttext-foreground
linknonetext-primary
sizeGeometryLabel size
smh-8 px-3text-sm
mdh-9 px-4text-sm
lgh-10 px-6text-base
iconh-9 w-9text-sm

Two recipes, and why

export const buttonVariants = fv(
"flex-row items-center justify-center gap-2 w-fit rounded-md transition duration-150",
{ variants: { variant: { … }, size: { … } }, defaultVariants: { variant: "default", size: "md" } },
);
export const buttonLabelVariants = fv("font-medium", {
variants: { variant: { … }, size: { … } },
defaultVariants: { variant: "default", size: "md" },
});

Nothing inherits on Roblox — text-sm on the button does not reach the label inside it, because text properties belong to the instance that draws the text. So the label needs its own recipe, keyed off the same two props. Both are exported, which matters for asChild below.

w-fit in the base is load-bearing: padding does not grow a frame on Roblox, so without an automatic width this renders zero pixels wide. The icon size overrides it with a concrete w-9, and the later token wins.

Disabled

Vela has no disabled: variant — disabled is Facet’s state, not the host’s — so the dimming is applied rather than selected:

const className = buttonVariants({
variant: props.variant,
size: props.size,
className: cn(disabled && "opacity-50", props.className),
});

Note where it goes: inside the recipe’s className slot, ahead of the consumer’s. Resolution is last-token-wins, so anything appended after props.className is an override the consumer cannot undo. button had this backwards until it was written down as a rule — Variants and classes.

The label fades rather than merely recolouring to text-muted-foreground, because that is what opacity does on the web: CSS fades an element and its text together, so a shadcn disabled:opacity-50 button dims its label too. Recolouring would be the more legible option, and it would make disabled mean two different things depending on which component you are looking at. Parity won; legibility is the price.

asChild

Renders the single child element instead of a textbutton, merging the recipe and the behavior props onto it through Lattice’s Slot.

<Button asChild variant="secondary" size="sm">
<textbutton key="AsChild" Text="AsChild" />
</Button>

Verified in Studio against a bare <textbutton>, which carries no styling of its own: the cloned instance came out with BackgroundColor3 0.153/0.153/0.165 (bg-secondary), Size {0,0},{0,32} and AutomaticSize.X (h-8 w-fit), bg-secondary/80 on hover, and UIListLayout, UICorner and UIPadding re-parented underneath it. The recipe crosses Slot whole.

asChild needs @lattice-ui/react-runtime@^0.8.0, and that floor is not about the feature existing. It was broken for a reason unrelated to className: Lattice keyed its UI modifier table by the lowercase JSX tag, while roblox-ts labels a host element with its Roblox class name, so <uicorner /> arrived as "UICorner", missed the lookup, and counted as a second slot target. Every Facet recipe emits at least a UIListLayout or a UICorner, so no component could use asChild at all until 0.8.0 fixed it upstream.

Neutral defaults

A bare <textbutton> renders an opaque grey box labelled “Button”. That is a look, and it has to be cleared before styling means anything:

const NEUTRAL_PROPS = {
AutoButtonColor: false,
BackgroundTransparency: 1,
BorderSizePixel: 0,
Text: "",
};

Spread order is neutral defaults → consumer passthrough → behavior props. Consumers can override appearance; they can never override behavior. Text is cleared because the label is a child instance, not this instance’s property.