npx facet-rbxts add buttonCopies 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" />import React from "@rbxts/react";import { Button } from "../ui/button";import { MODE } from "../facet-mode";
export function Buttons() { return ( <frame className="flex-col gap-3 w-full h-fit"> <frame className="flex-row items-center gap-2 w-full h-fit"> <Button Text="Default" /> <Button variant="secondary" Text="Secondary" /> <Button variant="outline" Text="Outline" /> <Button variant="destructive" Text="Destructive" /> </frame> <frame className="flex-row items-center gap-2 w-full h-fit"> <Button variant="ghost" Text="Ghost" /> <Button size="sm" Text="Small" /> <Button size="lg" Text="Large" /> <Button disabled Text="Disabled" /> </frame> </frame> );}import { fv, type VariantProps } from "@facet-ui/react-variants";import { composeEvents, getPassthroughProps, getSlotChild, type PassthroughProps, React, Slot, toSlotProps,} from "@lattice-ui/react-runtime";import { TextSlot } from "~/lib/text";import { cn } from "~/lib/utils";
// `w-fit` is load-bearing: padding does not grow a frame on Roblox, so without// an automatic width this renders zero pixels wide. The `icon*` sizes override it// with a concrete `size-*`, and the later token wins.//// The size names are shadcn's, which means the default one is called `default`// and not `md`. shadcn's set is eight: four that hug a label and four square ones// for a lone glyph.export const buttonVariants = fv( "flex-row shrink-0 items-center justify-center gap-2 w-fit rounded-md transition duration-150", { variants: { variant: { default: "bg-primary hover:bg-primary/90", destructive: "bg-destructive hover:bg-destructive/90", outline: "border border-input bg-background shadow-sm hover:bg-accent", secondary: "bg-secondary hover:bg-secondary/80", ghost: "hover:bg-accent", link: "", }, size: { xs: "h-6 gap-1 px-2", sm: "h-8 gap-1.5 px-3", default: "h-9 px-4 py-2", lg: "h-10 px-6", icon: "size-9", "icon-xs": "size-6", "icon-sm": "size-8", "icon-lg": "size-10", }, }, defaultVariants: { variant: "default", size: "default", }, },);
// Nothing inherits on Roblox, so the label needs its own recipe rather than// picking up `text-*` from the button.//// Only `xs` changes the type size. shadcn's base is `text-sm` and no size but// `xs` overrides it — a `lg` button is a taller button, not a bigger typeface.//// One token from shadcn is deliberately absent: `hover:text-accent-foreground`// on `outline` and `ghost`. Vela supports `hover:`, but a Roblox hover fires per// instance, and the label is a child of the button — so the colour would change// only while the pointer was over the glyphs themselves, not over the padding// the background already lit up. A half-working hover is worse than none.export const buttonLabelVariants = fv("whitespace-nowrap text-sm font-medium", { variants: { variant: { default: "text-primary-foreground", // shadcn writes a literal `text-white` here. Facet's theme still defines // the role Tailwind v4 dropped, and naming it keeps a retheme a config // edit. Same call as `badge`'s. destructive: "text-destructive-foreground", outline: "text-foreground", secondary: "text-secondary-foreground", ghost: "text-foreground", link: "text-primary", }, size: { xs: "text-xs", sm: "text-sm", default: "text-sm", lg: "text-sm", icon: "text-sm", "icon-xs": "text-xs", "icon-sm": "text-sm", "icon-lg": "text-sm", }, }, defaultVariants: { variant: "default", size: "default", },});
export type ButtonProps = VariantProps<typeof buttonVariants> & { /** * The button's label. Drawn as a styled child `textlabel`, not as this * instance's `Text` — so it can be sized and coloured independently, and sit * alongside an icon passed through `children`. */ Text?: string; /** Render the child element instead of a `textbutton`, merging behavior onto it. */ asChild?: boolean; disabled?: boolean; onClick?: () => void; children?: React.ReactNode;} & PassthroughProps<TextButton>;
const OWN_PROPS = ["variant", "size", "className", "Text", "asChild", "disabled", "onClick", "children"] as const;
// A bare `textbutton` renders an opaque grey box labelled "Button". Neutralize// that, then let the recipe and the consumer decide everything visual. `Text` is// cleared because the label is a child instance, not this instance's property.const NEUTRAL_PROPS = { AutoButtonColor: false, BackgroundTransparency: 1, BorderSizePixel: 0, Text: "",};
export function Button(props: ButtonProps) { const disabled = props.disabled === true;
// Vela has no `disabled:` variant — disabled is our state, not the host's — // so the dimming is applied here rather than selected by one. // // 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. See docs/decisions/class-conflicts.md. const className = buttonVariants({ variant: props.variant, size: props.size, className: cn(disabled && "opacity-50", props.className), });
const handleActivated = React.useCallback(() => { if (disabled) { return; } props.onClick?.(); }, [disabled, props.onClick]);
const passthrough = getPassthroughProps<TextButton>(props, OWN_PROPS); const behaviorProps = { Active: !disabled, Event: composeEvents(passthrough.Event, { Activated: handleActivated }), Selectable: !disabled, };
const content = ( <TextSlot Text={props.Text} // The label states its own fade, and states it as a prop. `opacity-50` // cannot cross this boundary in either direction: the button's alpha stops // at a component child, and a class on `TextSlot` resolves against a tag // the runtime cannot identify, so it drops `TextTransparency` and leaves // only a background that was already invisible. // See docs/decisions/opacity-does-not-cascade.md. TextTransparency={disabled ? 0.5 : 0} className={buttonLabelVariants({ variant: props.variant, size: props.size, })} > {props.children} </TextSlot> );
if (props.asChild === true) { if (getSlotChild(props.children) === undefined) { error("[Button] `asChild` requires a child element."); }
// Verified in Studio against a bare `<textbutton>`: the recipe crosses `Slot` // whole — background, size, automatic sizing, the hover variant, and the // `UICorner`/`UIListLayout`/`UIPadding` re-parented under the child. // // The label does not come with it. `TextSlot` never renders on this path, so // the child draws its own text at Roblox's 8px near-black default unless the // consumer styles it. `buttonLabelVariants` is exported for that. return ( <Slot className={className} {...toSlotProps(passthrough)} {...behaviorProps}> {props.children} </Slot> ); }
return ( <textbutton className={className} {...NEUTRAL_PROPS} {...passthrough} {...behaviorProps}> {content} </textbutton> );}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
| Prop | Type | Description |
|---|---|---|
| Text | string | The 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. |
| disabled | boolean | Dims to opacity-50, clears Active and Selectable, and swallows onClick. Not a Vela variant — Facet's own state. |
| onClick | () => void | Composed onto Activated rather than replacing it, so a passthrough Event handler still fires. |
| asChild | boolean | Render the single child element instead of a textbutton, merging the recipe and behavior onto it. Errors if there is no child element. |
| className | ClassName | Threaded 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. |
| children | React.ReactNode | Composition — 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
variant | Surface | Label |
|---|---|---|
default | bg-primary, hover:bg-primary/90 | text-primary-foreground |
destructive | bg-destructive, hover:bg-destructive/90 | text-destructive-foreground |
outline | border border-input bg-background, hover:bg-accent | text-foreground |
secondary | bg-secondary, hover:bg-secondary/80 | text-secondary-foreground |
ghost | none, hover:bg-accent | text-foreground |
link | none | text-primary |
size | Geometry | Label size |
|---|---|---|
sm | h-8 px-3 | text-sm |
md | h-9 px-4 | text-sm |
lg | h-10 px-6 | text-base |
icon | h-9 w-9 | text-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.
opacity-* composes into everything the compiler can see underneath an element — but a component
child is exactly what it cannot see, because the instances that component renders are created
somewhere else. Left alone, a disabled button sat at BackgroundTransparency 0.5 with its label at
TextTransparency 0: half a faded button.
Putting opacity-50 on the label’s own recipe does not fix it either. The class resolves against
__velaTag = TextSlot, and the runtime cannot know which instance a component will render, so it
drops the text-only half and keeps a background that was already invisible. The emitted Luau carried
the token; the label still measured 0.
So this is the one place a class genuinely cannot express the intent in either direction, and
TextTransparency={disabled ? 0.5 : 0} says it instead. Both halves were measured in Studio rather
than assumed.
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.
TextSlot never renders on this path — the child draws its own text — so buttonLabelVariants is
not applied and the text falls back to Roblox’s 8px near-black default. On a dark surface that is
invisible.
This is “nothing inherits” once more. A consumer reaching for asChild states the text styling on
their own element, and buttonLabelVariants is exported for exactly that:
<Button asChild variant="secondary" size="sm"> <textbutton className={buttonLabelVariants({ variant: "secondary", size: "sm" })} Text="AsChild" /></Button>Whether the registry should make this easier is still open.
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.