<Button>Save</Button> does not compile
<Button>Save</Button>// TS2747: 'Button' components don't accept text as child elements.roblox-ts React’s ReactNode has no string member, because host instances draw text from a Text
property rather than a text node. TypeScript also intersects a component’s children with
React.Attributes["children"], so widening the component’s own type does not help. It is TS2747
either way.
That is the shape of the platform, not a Facet decision — the only question is how to spell the escape hatch.
The answer: Text?: string
<Button Text="Save" /><Badge Text="New" /><CardTitle Text="Shop" />Three rules make it consistent:
Text?: stringon every component that draws a string itself, always in that component’sOWN_PROPSso it never reaches the host instance by accident.childrenkeeps shadcn’s meaning — composition: an icon, a nested element, anything that is not a bare string.- Two text regions means two components, never two props. A component that grows a
Textplus aDescriptionshould have been split.
Why uppercase, when every other Facet prop is lowercase
variant, size, asChild and disabled are lowercase because they belong to Facet. Uppercase is
the Roblox namespace, and Text is already a member of PassthroughProps<TextButton>.
Declare the prop as lowercase text and the name Text stays live on the passthrough path. A Roblox developer writes <Button Text="Save" />, the obvious thing to write, and it lands on the host instance. Roblox’s 8px near-black default is then drawn underneath the styled label, with nothing in the source explaining why.
Declaring Text shadows that and intercepts it. It is also what retired TextSlot’s lowercase text prop, which wrote Text={props.text} before spreading passthrough, so a stray Text silently won.
Text is not the nicer API, only the one the compiler allows, made uniform. If roblox-ts React ever
accepts string children, TextSlot already prefers children when Text is absent.
TextSlot
npx facet-rbxts add text # usually arrives as a dependency of button or badgeexport function TextSlot(props: TextSlotProps) { if (props.Text === undefined) { return <>{props.children}</>; }
return ( <textlabel className="size-fit" Text={props.Text} BackgroundTransparency={1} BorderSizePixel={0} {...getPassthroughProps<TextLabel>(props, OWN_PROPS)} /> );}Given Text it draws the styled label. Otherwise it renders children. That is the whole
component.
The text is a child instance, not the parent’s own Text property. That is what lets the label
be sized and coloured independently of the button, and sit beside an icon:
<Button Text="Save" /><Button Text="Save"><Icon glyph="check" /></Button>size-fit on that label is not decoration. A label with no resolved size collapses its parent’s
automatic sizing too, so a w-fit button around an unmeasured label is zero pixels wide.
TextSlot takes no className
<TextSlot className={buttonLabelVariants(…)}> becomes a runtime host, and the resolved TextColor3 / TextSize / FontFace arrive inside TextSlot as ordinary props for it to forward onto its textlabel.
Accepting a className prop and re-applying it inside would put TextSlot after the call site in
resolution order, and drop those props. That is what put every button label on Roblox’s 8px
near-black default.
The same trap waits for any component that wraps another component and expects to re-read
className from its own props.
Every text recipe declares a font-*
The rule that costs the most to learn by accident.
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, in the same
header, until someone opened it in Studio. Reading the emitted Luau did not catch it: the Luau was
correct, it simply never set FontFace.
Weight is not optional styling here. It is the only thing that says which font.
A label needs its own recipe
Nothing inherits: text-sm on a button does not reach the label inside it, because text properties
belong to the instance that draws the text.
export const buttonVariants = fv("… w-fit rounded-md"); // the buttonexport const buttonLabelVariants = fv("font-medium", { … }); // the textBoth key off the same variant and size props. That is the biggest structural difference from
shadcn, where one class list on the parent styles everything under it. It is why every Facet
component drawing a label exports two recipes.
Where a class cannot express it at all
There is one case in the current registry, and you hit it the moment you write a component that fades:
opacity-* composes into everything the compiler can see under an element, but not into a
component child, whose instances are created elsewhere. A class on TextSlot resolves against a
tag the runtime cannot identify, so it drops the text-only half.
So a disabled Button states the label’s fade as an instance prop:
<TextSlot Text={props.Text} TextTransparency={disabled ? 0.5 : 0} className={…} />props.children are unreachable for the same reason. A component that renders children onto a faded
surface has to state it there too.