Latticecomponents

Textarea

Multi-line input that auto-resizes within row bounds and separates change from commit.

@lattice-ui/react-textareaStable directionimport Textareadepends on runtime, focus, motion

Textarea wraps a multi-line Roblox TextBox. It owns the value, splits live changes from commit, and grows its height between configurable row bounds. It shares disabled / readOnly / required / invalid state across a label, description and message. For single-line input, Text Field is the sibling primitive.

Preview

Edit

Import

import { Textarea } from "@lattice-ui/react-textarea";

The package also exports the pure height helper used internally:

import { resolveTextareaHeight } from "@lattice-ui/react-textarea";

Anatomy

Only Root and Input are required. Label, Description and Message read shared state from context.

Textarea anatomy

Textarea anatomy
<Textarea.Root>
<Textarea.Label />
<Textarea.Input />
<Textarea.Description />
<Textarea.Message />
</Textarea.Root>
PartRequiredResponsibility
Textarea.RootyesOwns the value, commit callback, shared state, and the auto-resize bounds.
Textarea.InputyesThe multi-line TextBox that renders the value, reports changes, and resizes to fit.
Textarea.LabelnoA textbutton that focuses the input when activated.
Textarea.DescriptionnoA static textlabel for helper text.
Textarea.MessagenoA textlabel for validation text that recolors when invalid is set.

Examples

Basic labeled textarea

An uncontrolled field seeded with defaultValue, so the Root keeps the value and reports it through the callbacks. Auto-resize is on by default, so the Size you pass supplies the width and an initial height.

FeedbackField.tsx
import { Textarea } from "@lattice-ui/react-textarea";
export function FeedbackField() {
return (
<Textarea.Root defaultValue="" onValueCommit={(value) => print(`feedback: ${value}`)}>
<frame BackgroundTransparency={1} Size={UDim2.fromOffset(280, 160)}>
<uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Textarea.Label asChild>
<textbutton
AutoButtonColor={false}
BackgroundTransparency={1}
Size={UDim2.fromOffset(280, 22)}
Text="Feedback"
TextXAlignment={Enum.TextXAlignment.Left}
/>
</Textarea.Label>
<Textarea.Input asChild>
<textbox PlaceholderText="Tell us what you think" Size={UDim2.fromOffset(280, 68)} TextSize={15}>
<uipadding
PaddingBottom={new UDim(0, 7)}
PaddingLeft={new UDim(0, 10)}
PaddingRight={new UDim(0, 10)}
PaddingTop={new UDim(0, 7)}
/>
</textbox>
</Textarea.Input>
<Textarea.Description asChild>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(280, 20)}
Text="Sent to the developers with your session info."
TextXAlignment={Enum.TextXAlignment.Left}
/>
</Textarea.Description>
</frame>
</Textarea.Root>
);
}

Auto-resize with row bounds

minRows and maxRows set the bounds. Here a composer starts at two rows and grows until it hits six, from wrapped lines as well as explicit newlines. The primitive rewrites only the height, so give the input a fixed pixel width.

NoteComposer.tsx
import { Textarea } from "@lattice-ui/react-textarea";
export function NoteComposer() {
return (
<Textarea.Root autoResize minRows={2} maxRows={6}>
<Textarea.Input asChild>
<textbox PlaceholderText="Write a note" Size={UDim2.fromOffset(260, 50)} TextSize={15}>
<uipadding
PaddingBottom={new UDim(0, 7)}
PaddingLeft={new UDim(0, 10)}
PaddingRight={new UDim(0, 10)}
PaddingTop={new UDim(0, 7)}
/>
</textbox>
</Textarea.Input>
</Textarea.Root>
);
}

Put the input in a layout that tolerates height changes — a uilistlayout column reflows siblings automatically. resolveTextareaHeight gives the numbers without mounting anything.

Change vs commit

onValueChange fires on every text change, and onValueCommit once when editing ends. Change is for live UI: previews, counters, dirty indicators. Commit is for the expensive work.

ClanDescriptionField.tsx
import { useState } from "@rbxts/react";
import { Textarea } from "@lattice-ui/react-textarea";
export function ClanDescriptionField(props: { onSave: (description: string) => void }) {
const [draft, setDraft] = useState("A clan for casual raids.");
const [saved, setSaved] = useState("A clan for casual raids.");
return (
<Textarea.Root
value={draft}
onValueChange={setDraft}
onValueCommit={(committed) => {
setSaved(committed);
props.onSave(committed);
}}
minRows={2}
maxRows={5}
>
<frame BackgroundTransparency={1} Size={UDim2.fromOffset(260, 120)}>
<uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Textarea.Input asChild>
<textbox Size={UDim2.fromOffset(260, 50)} TextSize={15}>
<uipadding
PaddingBottom={new UDim(0, 7)}
PaddingLeft={new UDim(0, 10)}
PaddingRight={new UDim(0, 10)}
PaddingTop={new UDim(0, 7)}
/>
</textbox>
</Textarea.Input>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(260, 20)}
Text={draft === saved ? "Saved" : "Unsaved changes"}
TextColor3={Color3.fromRGB(170, 179, 195)}
TextXAlignment={Enum.TextXAlignment.Left}
/>
</frame>
</Textarea.Root>
);
}

Validation with invalid and Message

Validate on commit rather than every keystroke, then flip invalid on the Root. It is a shared flag: Message reads it from context and recolours to the error tone, and your own elements can branch on the same state. Swapping Message for Description keeps the layout height stable.

ReportField.tsx
import { useState } from "@rbxts/react";
import { Textarea } from "@lattice-ui/react-textarea";
export function ReportField() {
const [text, setText] = useState("");
const [error, setError] = useState(false);
return (
<Textarea.Root
value={text}
onValueChange={setText}
onValueCommit={(committed) => setError(committed.size() === 0)}
invalid={error}
required
minRows={3}
maxRows={8}
name="report"
>
<frame BackgroundTransparency={1} Size={UDim2.fromOffset(280, 160)}>
<uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Textarea.Label asChild>
<textbutton
AutoButtonColor={false}
BackgroundTransparency={1}
Size={UDim2.fromOffset(280, 22)}
Text="What happened?"
TextXAlignment={Enum.TextXAlignment.Left}
/>
</Textarea.Label>
<Textarea.Input asChild>
<textbox PlaceholderText="Describe the issue" Size={UDim2.fromOffset(280, 68)} TextSize={15}>
<uipadding
PaddingBottom={new UDim(0, 7)}
PaddingLeft={new UDim(0, 10)}
PaddingRight={new UDim(0, 10)}
PaddingTop={new UDim(0, 7)}
/>
</textbox>
</Textarea.Input>
{error ? (
<Textarea.Message asChild>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(280, 20)}
Text="A description is required."
TextXAlignment={Enum.TextXAlignment.Left}
/>
</Textarea.Message>
) : (
<Textarea.Description asChild>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(280, 20)}
Text="Include as much detail as you can."
TextXAlignment={Enum.TextXAlignment.Left}
/>
</Textarea.Description>
)}
</frame>
</Textarea.Root>
);
}

Character counter

For a length-limited field, control the value and truncate in onValueChange before the text reaches state. The TextBox text is bound to the owned value, so anything past the limit never appears.

BioField.tsx
import { useState } from "@rbxts/react";
import { Textarea } from "@lattice-ui/react-textarea";
const MAX_BIO = 200;
export function BioField() {
const [bio, setBio] = useState("");
return (
<Textarea.Root
value={bio}
onValueChange={(text) => setBio(text.sub(1, MAX_BIO))}
onValueCommit={(committed) => print(`bio saved: ${committed}`)}
minRows={3}
maxRows={6}
name="bio"
>
<frame BackgroundTransparency={1} Size={UDim2.fromOffset(260, 130)}>
<uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Textarea.Input asChild>
<textbox PlaceholderText="Tell other players about yourself" Size={UDim2.fromOffset(260, 68)} TextSize={15}>
<uipadding
PaddingBottom={new UDim(0, 7)}
PaddingLeft={new UDim(0, 10)}
PaddingRight={new UDim(0, 10)}
PaddingTop={new UDim(0, 7)}
/>
</textbox>
</Textarea.Input>
<textlabel
BackgroundTransparency={1}
Size={UDim2.fromOffset(260, 18)}
Text={`${bio.size()}/${MAX_BIO}`}
TextColor3={bio.size() >= MAX_BIO ? Color3.fromRGB(255, 128, 128) : Color3.fromRGB(170, 179, 195)}
TextXAlignment={Enum.TextXAlignment.Right}
/>
</frame>
</Textarea.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. The TextBox renders with MultiLine and TextWrapped on, top-aligned text, and ClearTextOnFocus off.

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.

Auto-resize

autoResize defaults to true. After each text change, and whenever the value changes externally, the Input measures its content and sets its height to rows × lineHeight + verticalPadding:

  • Rows is the larger of the newline count and the wrapped measurement, floored at 1 and clamped between the bounds. minRows defaults to 3. maxRows, when set, is raised to at least minRows.
  • Line height defaults to ceil(TextSize × 1.2) unless lineHeight is set on Input.
  • Vertical padding is summed from the input’s UIPadding children, falling back to 14.

Each measurement re-runs on a deferred frame, so TextBounds that settle late are picked up. The resize writes UDim2.fromOffset(currentWidth, height), replacing any scale-based sizing — size the input in pixel offsets when auto-resize is on. Set autoResize={false} to own the height yourself.

The same math is exposed as the pure resolveTextareaHeight(text, options), for computing a layout without mounting the component.

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. They differ in interaction — disabled also clears Active/Selectable, dims the text and suppresses the commit callback, while 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 Textarea.Message recolour to the error tone. Description never recolours, and name is passed through context and otherwise inert.

Label and focus

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

Textarea.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). 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.
autoResizebooleanGrows the input height to fit its content within the row bounds. Defaults to true.
minRowsnumberMinimum visible rows. Floored at 1. Defaults to 3.
maxRowsnumberMaximum visible rows before the height stops growing. Raised to at least minRows when set. Unbounded by default.
childrenReact.ReactNodeThe field parts.

Textarea.Input

PropTypeDescription
asChildbooleanMerge input behavior onto the single child element instead of rendering the default multi-line textbox. 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.
lineHeightnumberExplicit per-row pixel height used by auto-resize. Defaults to ceil(TextSize × 1.2).
childrenReact.ReactElementThe textbox element to render. Required when asChild is set.

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

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

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