Latticecomponents

Text Field

Single-line input owning the value, separating change from commit, with label and message parts.

@lattice-ui/react-text-fieldStable directionimport TextFielddepends on runtime, focus, motion

Text 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

Edit

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 anatomy
<TextField.Root>
<TextField.Label />
<TextField.Input />
<TextField.Description />
<TextField.Message />
</TextField.Root>
PartRequiredResponsibility
TextField.RootyesOwns the value, the commit callback, and shared disabled/readOnly/required/invalid state.
TextField.InputyesThe TextBox that renders the value and reports changes, focus, and commit.
TextField.LabelnoA textbutton that focuses the input when activated.
TextField.DescriptionnoA static textlabel for helper text.
TextField.MessagenoA 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.

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

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

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

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

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

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

API reference

TextField.Root

PropTypeDescription
valuestringControlled value. Pair with onValueChange.
defaultValuestringInitial value for uncontrolled usage. Defaults to an empty string.
onValueChange(value: string) => voidCalled on every text change while the field is editable. Not called when the new text equals the current value.
onValueCommit(value: string) => voidCalled 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.
disabledbooleanBlocks edits, clears Active/Selectable, dims the input, and suppresses commit. Defaults to false.
readOnlybooleanBlocks edits while keeping the field selectable and focusable; commit still fires. Defaults to false.
requiredbooleanShared flag exposed on context for your own validation wiring; not enforced. Defaults to false.
invalidbooleanMarks the field invalid and recolors the Message part to the error tone. Defaults to false.
namestringIdentifier passed through context for form usage. No behavior of its own.
childrenReact.ReactNodeThe field parts.

TextField.Input

PropTypeDescription
asChildbooleanMerge input behavior onto the single child element instead of the textbox the part renders. The child must be a textbox.
disabledbooleanLocal disabled state, combined with Root's via OR. Defaults to false.
readOnlybooleanLocal readOnly state, combined with Root's via OR. Defaults to false.
childrenReact.ReactElementThe textbox element to render. Required when asChild is set.

TextField.Label

PropTypeDescription
asChildbooleanMerge label behavior (focus-on-activate, disabled state) onto the single child element instead of the textbutton the part renders.
childrenReact.ReactElementThe element to render. Required when asChild is set.

TextField.Description

PropTypeDescription
asChildbooleanMerge the description onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own.
childrenReact.ReactElementThe element to render. Required when asChild is set.

TextField.Message

PropTypeDescription
asChildbooleanMerge the message onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own.
childrenReact.ReactElementThe element to render. Required when asChild is set.