Facetguides

Text and labels

Why text is an uppercase prop, what TextSlot does, and the two rules every text recipe obeys.

<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:

  1. Text?: string on every component that draws a string itself, always in that component’s OWN_PROPS so it never reaches the host instance by accident.
  2. children keeps shadcn’s meaning — composition: an icon, a nested element, anything that is not a bare string.
  3. Two text regions means two components, never two props. A component that grows a Text plus a Description should 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>.

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

Terminal window
npx facet-rbxts add text # usually arrives as a dependency of button or badge
export 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

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 button
export const buttonLabelVariants = fv("font-medium", { … }); // the text

Both 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.