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 them, and Event is composed rather
than replaced.
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, 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.
opacity-* composes into everything the compiler can see underneath an element. A component child is exactly what it cannot see, because the instances it renders are created elsewhere. Left alone, a disabled button sat at BackgroundTransparency 0.5 with its label at TextTransparency 0: half a faded button.
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 renders, so it drops
the text-only half. The emitted Luau carried the token. The label still measured 0.
So this is the one place a class cannot express the intent in either direction, and TextTransparency={disabled ? 0.5 : 0} says it instead. Both halves were measured in Studio.
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.
TextSlot never renders on this path, so buttonLabelVariants is not applied and the text falls back to Roblox’s 8px near-black default — invisible on a dark surface.
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. 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.