npx facet-rbxts add text-fieldCopies 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>import React from "@rbxts/react";import { TextField, TextFieldDescription, TextFieldInput, TextFieldLabel, TextFieldMessage,} from "../ui/text-field";import { MODE } from "../facet-mode";
/** * `invalid` is set on the root *and* on the input: the root's copy is what * carries the state into Lattice's context, the input's is what turns the * border `border-destructive`. Nothing inherits, so both are load-bearing. */export function DisplayName() { return ( <frame className="flex-col gap-4 w-full h-fit"> <TextField defaultValue="Nimbus_Rider"> <TextFieldLabel Text="Display name" /> <TextFieldInput /> <TextFieldDescription Text="Shown to other players in this server." /> </TextField> <TextField invalid defaultValue="!!"> <TextFieldLabel Text="Clan tag" /> <TextFieldInput invalid /> <TextFieldMessage Text="Letters and numbers only." /> </TextField> </frame> );}import { fv } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React, toSlotProps } from "@lattice-ui/react-runtime";import { TextField as TextFieldPrimitive } from "@lattice-ui/react-text-field";import { type ClassName, cn } from "~/lib/utils";
/** * A `textbox` draws its own text, so unlike a button there is no second label * recipe here — the text classes sit on the input itself, and `font-normal` is * load-bearing: without a `font-*` the box renders in LegacyArial, not in the * theme's typeface. * * `invalid` and `disabled` are visual states of the input but props of the * root, because Lattice's context is what carries them to the behavior; the * input takes them again for its own border and fade since nothing inherits. * * One recipe object rather than five exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const textFieldVariants = { root: fv("flex-col gap-2 w-full h-fit"), input: fv( "h-9 w-full min-w-0 rounded-md border border-input shadow-sm px-3 py-1 text-left text-sm font-normal text-foreground placeholder-muted-foreground focus:border-ring", ), label: fv("w-full h-fit text-left text-sm font-medium text-foreground"), description: fv("w-full h-fit whitespace-normal leading-normal text-left text-sm font-normal text-muted-foreground"), message: fv("w-full h-fit whitespace-normal text-left text-sm font-normal text-destructive"),};
export type TextFieldProps = { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** Fires when the box loses focus, with the text as it stands. */ onValueCommit?: (value: string) => void; disabled?: boolean; readOnly?: boolean; invalid?: boolean; className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type TextFieldInputProps = { disabled?: boolean; invalid?: boolean; className?: ClassName;} & PassthroughProps<TextBox>;
export type TextFieldTextProps = { Text?: string; className?: ClassName } & PassthroughProps<TextLabel>;export type TextFieldLabelProps = { Text?: string; className?: ClassName } & PassthroughProps<TextButton>;
// The forwarded bag is widened by `toSlotProps` and then has `children`// dropped from its *type*: these parts type `children` as a single element// (what `asChild` merges onto), and the bag never actually carries one —// `children` is listed as an own prop — so only the type needs narrowing.function forwardProps(props: object, ownKeys: readonly string[]): { key?: React.Key } & { [index: string]: unknown } { return toSlotProps(getPassthroughProps(props, ownKeys));}
const ROOT_OWN_PROPS = [ "value", "defaultValue", "onValueChange", "onValueCommit", "disabled", "readOnly", "invalid", "className", "children",] as const;const INPUT_OWN_PROPS = ["disabled", "invalid", "className", "children"] as const;const TEXT_OWN_PROPS = ["Text", "className", "children"] as const;
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
export function TextField(props: TextFieldProps) { return ( <TextFieldPrimitive.Root defaultValue={props.defaultValue} disabled={props.disabled} invalid={props.invalid} onValueChange={props.onValueChange} onValueCommit={props.onValueCommit} readOnly={props.readOnly} value={props.value} > <frame className={cn(textFieldVariants.root({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, ROOT_OWN_PROPS)} > {props.children} </frame> </TextFieldPrimitive.Root> );}
export function TextFieldInput(props: TextFieldInputProps) { const disabled = props.disabled === true;
return ( <TextFieldPrimitive.Input className={textFieldVariants.input({ className: cn(props.invalid === true && "border-destructive", disabled && "opacity-50", props.className), })} disabled={props.disabled} {...forwardProps(props, INPUT_OWN_PROPS)} /> );}
export function TextFieldLabel(props: TextFieldLabelProps) { return ( <TextFieldPrimitive.Label className={cn(textFieldVariants.label({ className: props.className }))} Text={props.Text ?? ""} {...forwardProps(props, TEXT_OWN_PROPS)} /> );}
export function TextFieldDescription(props: TextFieldTextProps) { return ( <TextFieldPrimitive.Description className={cn(textFieldVariants.description({ className: props.className }))} Text={props.Text ?? ""} {...forwardProps(props, TEXT_OWN_PROPS)} /> );}
export function TextFieldMessage(props: TextFieldTextProps) { return ( <TextFieldPrimitive.Message className={cn(textFieldVariants.message({ className: props.className }))} Text={props.Text ?? ""} {...forwardProps(props, TEXT_OWN_PROPS)} /> );}The parts
| Part | Renders | Classes |
|---|---|---|
TextField | Frame | flex-col gap-2 w-full h-fit |
TextFieldInput | TextBox | h-9 w-full rounded-md border border-input px-3 text-left text-sm font-normal text-foreground placeholder-muted-foreground focus:border-ring |
TextFieldLabel | TextButton | w-full h-fit text-left text-sm font-medium text-foreground |
TextFieldDescription | TextLabel | w-full h-fit whitespace-normal text-left text-xs font-normal text-muted-foreground |
TextFieldMessage | TextLabel | w-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 <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
| Prop | Type | Description |
|---|---|---|
| value | string | Controlled value. Pass it with onValueChange. |
| defaultValue | string | Uncontrolled starting text. |
| onValueChange | (value: string) => void | Fires on every keystroke. |
| onValueCommit | (value: string) => void | Fires when the box loses focus, with the text as it stands. |
| disabled | boolean | Blocks focus and typing. |
| readOnly | boolean | Focusable, not editable. |
| invalid | boolean | Carries the state into Lattice's context. It does not colour anything on its own — see below. |
| className | ClassName | Threaded 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
| Prop | Type | Description |
|---|---|---|
| invalid | boolean | Adds border-destructive. This is the one that draws the red border. |
| disabled | boolean | Adds opacity-50. |
| className | ClassName | Threaded 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.
<TextField invalid> with a plain <TextFieldInput /> compiles and renders a normally-bordered box
that Lattice considers invalid. Nothing warns — the parts have no relationship for anything to check.
If the repetition bothers you in your project, the file is yours. Read the primitive’s context in TextFieldInput, or collapse the five parts into one component that takes three strings.
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.