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