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 them, and Event is composed rather than replaced.

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, and 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.

Disabled

Vela has no disabled: variant, because disabled is Facet’s state and not the host’s. 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 — Variants and classes.

The label fades rather than recolouring to text-muted-foreground, because that is what opacity does on the web. Recolouring would be more legible, but it would make disabled mean two different things across components. Parity won, and 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>: the cloned instance came out with bg-secondary, h-8 w-fit, bg-secondary/80 on hover, and UIListLayout, UICorner and UIPadding re-parented underneath. The recipe crosses Slot whole.

asChild needs @lattice-ui/react-runtime@^0.8.0. Below that, Lattice keyed its UI modifier table by the lowercase JSX tag, while roblox-ts labels a host element with its Roblox class name. Every modifier 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.

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, so consumers override appearance but never behavior. Text is cleared because the label is a child instance, not this instance’s property.