@lattice-ui/react-text-fieldStable directionimport TextFielddepends on runtime, focus, motionText Field wraps a Roblox TextBox for single-line input. It owns the value, separates a live
change from a commit, and shares disabled / readOnly / required / invalid state across a label,
description and message. For multi-line input, Textarea is the
sibling primitive.
Preview
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
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, so the Root keeps the value and reports it through
the callbacks. Clicking the label captures focus on the input.
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 needs to read or set the text. The owned
value is mirrored onto the TextBox, so setting state from outside updates the visible text.
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, and onValueCommit once when editing ends. Change is
for live UI: previews, counters, filtering. Commit is for the expensive work.
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. Message reads
that flag from context and recolours to the error tone. Swapping Message for Description keeps
the layout height stable.
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 freeze the value but differ in interaction. disabled clears Active and Selectable, dims
the text, ignores edits and suppresses the commit callback. readOnly keeps the field focusable and
copyable but rejects edits, and a focus loss still fires onValueCommit.
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, where you parse and clamp.
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
Pass value and onValueChange to control it, or defaultValue to run uncontrolled. The owned
value is bound to the TextBox’s Text, so the display tracks state rather than whatever Roblox
last typed.
Change and commit
A keystroke calls onValueChange, and a commit happens on FocusLost. Commit is not a diff: it
fires on every focus loss, unchanged text included, and is suppressed only while disabled. A
readOnly field still commits.
Disabled and readOnly
Both stop edits from updating the value. Root.setValue ignores incoming text and the Input
rewrites the TextBox back, so stray platform input cannot desync the field. disabled also clears
Active and Selectable, dims the text and suppresses the commit callback. readOnly only drops
TextEditable. Input can set either locally, combining with Root’s state via OR.
Required, invalid, and name
Both are shared flags with no enforcement: required is exposed on context for your validation
logic, invalid makes TextField.Message recolour to the error tone. Description never
recolours, and name is passed through context and otherwise inert.
Label and focus
TextField.Label renders a textbutton whose activation calls CaptureFocus() on the input. While
disabled it drops Active and Selectable. Description and Message are non-interactive labels
that dim alongside a disabled field.
Input focus motion
The Input tracks focus and exposes it through context, but paints nothing. Focus while disabled
or readOnly does not count as active, so a read-only field does not light up as editable. Branch
on the state yourself, with a createFieldResponseRecipe() if you want it eased.
Every part renders unstyled and carries no copy. Input is a textbox with ClearTextOnFocus off and no size or placeholder. Label, Description and Message render nothing until you supply it.
Pass the copy and styling as props, or use asChild for a different element class. All four render children, so a uipadding attaches directly.
onValueCommit is not change detection — it fires whenever editing ends, even on identical text, and only disabled suppresses it. Dedupe against the last saved value yourself.
With asChild the Input merges its Text binding, editability flags and handlers onto your single child, and its ref wiring accepts only a TextBox.
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. |