Facetcomponents

Text field

Five parts around a TextBox — and the component where a state has to be stated twice because nothing inherits.

Terminal window
npx facet-rbxts add text-field

Copies ui/text-field.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants, @lattice-ui/react-runtime@^0.8.0 and @lattice-ui/react-text-field@^0.8.0.

import {
TextField,
TextFieldDescription,
TextFieldInput,
TextFieldLabel,
TextFieldMessage,
} from "../shared/ui/text-field";
<TextField defaultValue="Nimbus_Rider" onValueCommit={saveName}>
<TextFieldLabel Text="Display name" />
<TextFieldInput />
<TextFieldDescription Text="Shown to other players in this server." />
</TextField>
A valid field and an invalid one. Click into either — the box is a real TextBox.

The parts

PartRendersClasses
TextFieldFrameflex-col gap-2 w-full h-fit
TextFieldInputTextBoxh-9 w-full rounded-md border border-input px-3 text-left text-sm font-normal text-foreground placeholder-muted-foreground focus:border-ring
TextFieldLabelTextButtonw-full h-fit text-left text-sm font-medium text-foreground
TextFieldDescriptionTextLabelw-full h-fit whitespace-normal text-left text-xs font-normal text-muted-foreground
TextFieldMessageTextLabelw-full h-fit whitespace-normal text-left text-xs font-medium text-destructive

The label renders a TextButton rather than a TextLabel because Lattice makes it focus the input when pressed — the Roblox equivalent of <label for>.

Renders a TextBox. Unknown props forward onto it and are type-checked against it, so a prop TextBox does not accept is a compile error.

That is TextFieldInput. TextField forwards onto a Frame, the two text parts onto a TextLabel, and the label onto a TextButton.

Props

TextField

PropTypeDescription
valuestringControlled value. Pass it with onValueChange.
defaultValuestringUncontrolled starting text.
onValueChange(value: string) => voidFires on every keystroke.
onValueCommit(value: string) => voidFires when the box loses focus, with the text as it stands.
disabledbooleanBlocks focus and typing.
readOnlybooleanFocusable, not editable.
invalidbooleanCarries the state into Lattice's context. It does not colour anything on its own — see below.
classNameClassNameThreaded into the root 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.

TextFieldInput

PropTypeDescription
invalidbooleanAdds border-destructive. This is the one that draws the red border.
disabledbooleanAdds opacity-50.
classNameClassNameThreaded into the input recipe's className slot inside the component.

TextFieldLabel, TextFieldDescription and TextFieldMessage each take Text and className.

invalid is stated twice, and that is not a bug

<TextField invalid>
<TextFieldLabel Text="Clan tag" />
<TextFieldInput invalid />
<TextFieldMessage Text="Letters and numbers only." />
</TextField>

The root’s invalid is what reaches Lattice’s context — the behavior half. The input’s invalid is what turns the border border-destructive — the appearance half.

They are separate because nothing inherits. A TextBox’s stroke colour lives on that TextBox; there is no descendant selector and no cascade to carry the root’s state down to it. It is the same repetition Alert has with variant, reached from a different direction.

font-normal on the input is load-bearing

A TextBox draws its own text, so unlike Button there is no second label recipe here — the text classes sit on the input itself. font-normal is in that list not for weight but for existence: Vela leaves FontFace alone when no font-* token appears, and Roblox’s untouched default is LegacyArial. That is the bug that shipped in card, and every text-drawing instance in the registry states a font-* because of it.

placeholder-muted-foreground colours PlaceholderColor3; focus:border-ring swaps the stroke while the box has focus.