@lattice-ui/react-text-fieldStable directionimport TextFielddepends on runtime, focus, motionText Field is the primitive for any single-line input: a username box, a search bar, a price entry, a private-server name. It wraps a Roblox TextBox, owns the current value, and distinguishes a live change (every keystroke) from a commit (when editing ends), so your component decides when to validate or persist.
Reach for Text Field when an input needs controlled or uncontrolled value state, a clear split between change and commit, and shared disabled / readOnly / required / invalid state that flows to a label, helper description, and validation message. For multi-line input with auto-resize, Textarea is the sibling primitive.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
import { React } from "@lattice-ui/react-runtime";import { mergeGuiProps, Text, useTheme } from "@lattice-ui/react-style";import { TextField } from "@lattice-ui/react-text-field";import { buttonRecipe } from "../../../playground/src/client/theme/recipes";
function TextFieldExample() { const { theme } = useTheme();
return ( <frame BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)}> <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[20])} PaddingLeft={new UDim(0, theme.space[20])} PaddingRight={new UDim(0, theme.space[20])} PaddingTop={new UDim(0, theme.space[20])} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[6])} />
<frame BackgroundTransparency={1} LayoutOrder={0} Size={UDim2.fromOffset(280, 48)}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamBold} Size={UDim2.fromOffset(280, 18)} Text="Join the newsletter" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.bodyMd.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(0, 22)} Size={UDim2.fromOffset(280, 16)} Text="Product updates, once a month." TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame>
<TextField.Root> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} LayoutOrder={1} Size={UDim2.fromOffset(280, 0)} > <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[6])} />
<TextField.Label asChild> <textbutton AutoButtonColor={false} BackgroundTransparency={1} BorderSizePixel={0} Font={Enum.Font.GothamMedium} LayoutOrder={0} Size={UDim2.fromOffset(280, 16)} Text="Email" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </TextField.Label>
<frame BackgroundColor3={theme.colors.surface} BorderSizePixel={0} LayoutOrder={1} Size={UDim2.fromOffset(280, 40)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <TextField.Input asChild> <textbox BackgroundTransparency={1} BorderSizePixel={0} PlaceholderColor3={theme.colors.textSecondary} PlaceholderText="you@example.com" Size={UDim2.fromScale(1, 1)} Text="" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} > <uipadding PaddingLeft={new UDim(0, theme.space[12])} PaddingRight={new UDim(0, theme.space[12])} /> </textbox> </TextField.Input> </frame>
<TextField.Description asChild> <Text BackgroundTransparency={1} LayoutOrder={2} Size={UDim2.fromOffset(280, 16)} Text="No spam — unsubscribe anytime." TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </TextField.Description> </frame> </TextField.Root>
<frame BackgroundTransparency={1} LayoutOrder={2} Size={UDim2.fromOffset(280, 48)}> <textbutton {...(mergeGuiProps(buttonRecipe({ intent: "primary", size: "sm" }, theme), { AnchorPoint: new Vector2(0, 1), Position: UDim2.fromScale(0, 1), Size: UDim2.fromOffset(280, 38), Text: "Subscribe", TextSize: theme.typography.labelSm.textSize, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> </textbutton> </frame> </frame> );}Import
import { TextField } from "@lattice-ui/react-text-field";Anatomy
Compose Root around an Input, plus any of the optional text parts. Only Root and Input are required; Label, Description, and Message read shared state from context.
TextField anatomy
<TextField.Root> <TextField.Label /> <TextField.Input /> <TextField.Description /> <TextField.Message /></TextField.Root>| Part | Required | Responsibility |
|---|---|---|
TextField.Root | yes | Owns the value, the commit callback, and shared disabled/readOnly/required/invalid state. |
TextField.Input | yes | The TextBox that renders the value and reports changes, focus, and commit. |
TextField.Label | no | A textbutton that focuses the input when activated. |
TextField.Description | no | A static textlabel for helper text. |
TextField.Message | no | A textlabel for validation text that recolors when invalid is set. |
Examples
Basic labeled field
An uncontrolled field seeded with defaultValue: the Root keeps the value internally and you only hear about it through the callbacks. Label, Input, and Description each take asChild to project their behavior onto your own elements — clicking the label captures focus on the input, and the description is plain helper text that dims with the field.
import { TextField } from "@lattice-ui/react-text-field";
export function DisplayNameField() { return ( <TextField.Root defaultValue="" onValueCommit={(value) => print(`display name: ${value}`)}> <frame BackgroundTransparency={1} Size={UDim2.fromOffset(240, 88)}> <uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<TextField.Label asChild> <textbutton AutoButtonColor={false} BackgroundTransparency={1} Size={UDim2.fromOffset(240, 22)} Text="Display name" TextXAlignment={Enum.TextXAlignment.Left} /> </TextField.Label>
<TextField.Input asChild> <textbox PlaceholderText="Enter a name" Size={UDim2.fromOffset(240, 36)} TextXAlignment={Enum.TextXAlignment.Left}> <uipadding PaddingLeft={new UDim(0, 10)} PaddingRight={new UDim(0, 10)} /> </textbox> </TextField.Input>
<TextField.Description asChild> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(240, 20)} Text="Shown to other players." TextXAlignment={Enum.TextXAlignment.Left} /> </TextField.Description> </frame> </TextField.Root> );}Controlled value
Pass value and onValueChange when something outside the field needs to read or set the text — filtering a list as the player types, or resetting the field from a button. Because the owned value is mirrored onto the TextBox, setting state from outside (like the clear button here) updates the visible text too.
import { useState } from "@rbxts/react";import { TextField } from "@lattice-ui/react-text-field";
export function ItemSearchField(props: { onQueryChange: (query: string) => void }) { const [query, setQuery] = useState("");
const updateQuery = (nextQuery: string) => { setQuery(nextQuery); props.onQueryChange(nextQuery); };
return ( <TextField.Root value={query} onValueChange={updateQuery}> <frame BackgroundTransparency={1} Size={UDim2.fromOffset(300, 36)}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} Padding={new UDim(0, 8)} />
<TextField.Input asChild> <textbox PlaceholderText="Search items" Size={UDim2.fromOffset(240, 36)} TextXAlignment={Enum.TextXAlignment.Left}> <uipadding PaddingLeft={new UDim(0, 10)} PaddingRight={new UDim(0, 10)} /> </textbox> </TextField.Input>
<textbutton BackgroundColor3={Color3.fromRGB(59, 66, 84)} Event={{ Activated: () => updateQuery("") }} Size={UDim2.fromOffset(52, 36)} Text="Clear" TextColor3={Color3.fromRGB(236, 241, 249)} /> </frame> </TextField.Root> );}Change vs commit
onValueChange fires on every text change; onValueCommit fires once when editing ends — the TextBox loses focus because the player pressed Enter or clicked away. Use change for live UI (previews, counters, filtering) and commit for the expensive or one-shot work: saving to the server, running validation, recording history. Here the draft updates live while the save only happens on commit.
import { useState } from "@rbxts/react";import { TextField } from "@lattice-ui/react-text-field";
export function ServerNameField(props: { onSave: (name: string) => void }) { const [draft, setDraft] = useState("My Server"); const [saved, setSaved] = useState("My Server");
return ( <TextField.Root value={draft} onValueChange={setDraft} onValueCommit={(committed) => { setSaved(committed); props.onSave(committed); }} > <frame BackgroundTransparency={1} Size={UDim2.fromOffset(240, 64)}> <uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<TextField.Input asChild> <textbox Size={UDim2.fromOffset(240, 36)} TextXAlignment={Enum.TextXAlignment.Left}> <uipadding PaddingLeft={new UDim(0, 10)} PaddingRight={new UDim(0, 10)} /> </textbox> </TextField.Input>
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(240, 20)} Text={draft === saved ? "Saved" : "Unsaved changes"} TextColor3={Color3.fromRGB(170, 179, 195)} TextXAlignment={Enum.TextXAlignment.Left} /> </frame> </TextField.Root> );}Validation with invalid and Message
Validate on commit rather than every keystroke, then flip invalid on the Root. invalid is a shared flag: the Message part reads it from context and recolors its text to the error tone, and your own elements can branch on the same state. Swapping Message for Description keeps the layout height stable while switching between helper and error copy.
import { useState } from "@rbxts/react";import { TextField } from "@lattice-ui/react-text-field";
export function UsernameField() { const [name, setName] = useState(""); const [error, setError] = useState(false);
return ( <TextField.Root value={name} onValueChange={setName} onValueCommit={(committed) => setError(committed.size() < 3)} invalid={error} required name="username" > <frame BackgroundTransparency={1} Size={UDim2.fromOffset(240, 88)}> <uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<TextField.Input asChild> <textbox PlaceholderText="Enter a name" Size={UDim2.fromOffset(240, 36)} TextXAlignment={Enum.TextXAlignment.Left}> <uipadding PaddingLeft={new UDim(0, 10)} PaddingRight={new UDim(0, 10)} /> </textbox> </TextField.Input>
{error ? ( <TextField.Message asChild> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(240, 20)} Text="At least 3 characters." TextXAlignment={Enum.TextXAlignment.Left} /> </TextField.Message> ) : ( <TextField.Description asChild> <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(240, 20)} Text="Visible to other players." TextXAlignment={Enum.TextXAlignment.Left} /> </TextField.Description> )} </frame> </TextField.Root> );}Disabled and readOnly
Both flags freeze the value, but they differ in interaction. disabled takes the input out of play entirely — it clears Active/Selectable, dims the text, ignores edits, and suppresses the commit callback. readOnly keeps the field focusable and selectable (players can still click into it and copy the text) but rejects edits; a focus loss still fires onValueCommit with the unchanged text. Here the join code is copyable but locked, while the region field is fully inert until unlocked.
import { TextField } from "@lattice-ui/react-text-field";
export function ServerInfoFields(props: { joinCode: string; canEditRegion: boolean }) { return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(240, 80)}> <uilistlayout Padding={new UDim(0, 8)} SortOrder={Enum.SortOrder.LayoutOrder} />
<TextField.Root value={props.joinCode} readOnly> <TextField.Input /> </TextField.Root>
<TextField.Root defaultValue="EU-West" disabled={!props.canEditRegion}> <TextField.Input /> </TextField.Root> </frame> );}Filtered input
For numeric-only entry, control the value and strip rejected characters in onValueChange before they reach state. The TextBox text is bound to the owned value, so the field tracks the filtered result. Run the same normalization at commit — onValueCommit receives the box’s final text, so it is the place to parse and clamp the settled value.
import { useState } from "@rbxts/react";import { TextField } from "@lattice-ui/react-text-field";
const MAX_BET = 500;
export function BetAmountField(props: { onBetChange: (amount: number) => void }) { const [amount, setAmount] = useState("0");
return ( <TextField.Root value={amount} onValueChange={(text) => { const [digits] = text.gsub("%D", ""); setAmount(digits); }} onValueCommit={(text) => { const [digits] = text.gsub("%D", ""); const clamped = math.clamp(tonumber(digits) ?? 0, 0, MAX_BET); setAmount(tostring(clamped)); props.onBetChange(clamped); }} > <TextField.Input asChild> <textbox PlaceholderText="0" Size={UDim2.fromOffset(120, 36)} TextXAlignment={Enum.TextXAlignment.Left}> <uipadding PaddingLeft={new UDim(0, 10)} PaddingRight={new UDim(0, 10)} /> </textbox> </TextField.Input> </TextField.Root> );}How it behaves
Value state
TextField.Root is controllable. Pass value and onValueChange to control it, or defaultValue to run uncontrolled; when neither is set the value starts empty. The owned value is bound to the TextBox’s Text, so the displayed text tracks the state rather than whatever Roblox last typed. Setting the same value again is a no-op — onValueChange only fires when the text actually differs from the current value.
Change and commit
A keystroke fires the TextBox text change, which calls onValueChange with the new text. A commit happens on FocusLost — when the player presses Enter, clicks away, or otherwise ends editing — and calls onValueCommit with the text as it stands in the box at that moment. Commit is not a diff: it fires on every focus loss, including when the text is unchanged, and it is suppressed only while disabled (a readOnly field still commits its unchanged text). Use onValueChange for live updates and onValueCommit for validation or persistence you only want once editing settles.
Disabled and readOnly
disabled and readOnly both stop edits from updating the value: while either is set, Root.setValue ignores incoming text and the Input rewrites the TextBox back to the owned value, so stray platform input cannot desync the field. They differ in interaction — disabled also clears Active/Selectable, makes the text non-editable, and dims the input text, while readOnly only drops TextEditable, keeping the field selectable and focusable. A disabled field additionally suppresses the commit callback on focus loss. Input can also set disabled/readOnly locally, which combine with Root’s state via OR — useful for locking one input inside an otherwise live field.
Required, invalid, and name
required and invalid are shared flags that carry no enforcement on their own — required is exposed on context for your own validation and submission logic, and invalid is a visual/semantic marker. When invalid is set, TextField.Message recolors its text to the error tone (RGB(255, 128, 128)); Description never recolors for validity. The name prop is passed through context for form identification and is otherwise inert.
Label and focus
TextField.Label renders a textbutton; activating it calls CaptureFocus() on the input’s TextBox, so clicking the label focuses the field. While the field is disabled the label drops its Active/Selectable state, dims, and does nothing on activation. Description and Message are non-interactive labels that read shared state for their text color and dim alongside a disabled field.
Input focus motion
The Input tracks focus and exposes it through context, but no longer paints it. It used to animate its BackgroundColor3 toward a focused accent with the field response recipe; as of 0.7.0 the primitive writes no color, in either mode. Focus while disabled or readOnly still does not count as active, so a read-only field should not light up as editable — branch on the state yourself, and add a createFieldResponseRecipe() response motion if you want the change to ease.
Every part renders unstyled, and none of them carry copy. Input is a textbox with ClearTextOnFocus off and no size or placeholder of its own; Label, Description, and Message used to render the literal text "Label", "Description", and "Message", and as of 0.7.0 render nothing until you supply it.
Pass the copy and styling as props — they forward onto the instance each part renders — or use asChild when you need a different element class. All four also render children now, so a uipadding or uicorner attaches directly.
onValueCommit is not change detection — it fires whenever editing ends, even if the text is identical to the last commit, and readOnly does not suppress it (only disabled does). If you persist on commit, dedupe against the last saved value yourself, as in the change-vs-commit example.
With asChild, the Input merges its Text binding, editability flags, and Focused/FocusLost/text-change handlers onto your single child, and its ref wiring only accepts instances that are a TextBox. Use a textbox element; a textlabel or frame will not report changes or focus.
API reference
TextField.Root
| Prop | Type | Description |
|---|---|---|
| value | string | Controlled value. Pair with onValueChange. |
| defaultValue | string | Initial value for uncontrolled usage. Defaults to an empty string. |
| onValueChange | (value: string) => void | Called on every text change while the field is editable. Not called when the new text equals the current value. |
| onValueCommit | (value: string) => void | Called with the box's final text when editing ends (focus lost via Enter or clicking away). Fires even when the text is unchanged; suppressed while disabled. |
| disabled | boolean | Blocks edits, clears Active/Selectable, dims the input, and suppresses commit. Defaults to false. |
| readOnly | boolean | Blocks edits while keeping the field selectable and focusable; commit still fires. Defaults to false. |
| required | boolean | Shared flag exposed on context for your own validation wiring; not enforced. Defaults to false. |
| invalid | boolean | Marks the field invalid and recolors the Message part to the error tone. Defaults to false. |
| name | string | Identifier passed through context for form usage. No behavior of its own. |
| children | React.ReactNode | The field parts. |
TextField.Input
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge input behavior onto the single child element instead of the textbox the part renders. The child must be a textbox. |
| disabled | boolean | Local disabled state, combined with Root's via OR. Defaults to false. |
| readOnly | boolean | Local readOnly state, combined with Root's via OR. Defaults to false. |
| children | React.ReactElement | The textbox element to render. Required when asChild is set. |
TextField.Label
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge label behavior (focus-on-activate, disabled state) onto the single child element instead of the textbutton the part renders. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
TextField.Description
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the description onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
TextField.Message
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge the message onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own. |
| children | React.ReactElement | The element to render. Required when asChild is set. |