@lattice-ui/react-textareaStable directionimport Textareadepends on runtime, focus, motionTextarea is the primitive for multi-line input: a report description, a tribe message, feedback, or any field where text spans several lines. It wraps a multi-line Roblox TextBox, owns the value, splits live changes from commit, and grows its height to fit the content between configurable row bounds.
Reach for Textarea when an input needs controlled or uncontrolled value state, a clean change-versus-commit split, auto-resizing that clamps between a minimum and maximum number of rows, and shared disabled / readOnly / required / invalid state across a label, description, and message. For single-line input without the height machinery, Text Field 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 { Textarea } from "@lattice-ui/react-textarea";import { buttonRecipe } from "../../../playground/src/client/theme/recipes";
function TextareaExample() { 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="Send feedback" 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="Help us improve the component library." TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </frame>
<Textarea.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])} />
<Textarea.Label asChild> <textbutton AutoButtonColor={false} BackgroundTransparency={1} BorderSizePixel={0} Font={Enum.Font.GothamMedium} LayoutOrder={0} Size={UDim2.fromOffset(280, 16)} Text="Message" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </Textarea.Label>
<frame BackgroundColor3={theme.colors.surface} BorderSizePixel={0} LayoutOrder={1} Size={UDim2.fromOffset(280, 92)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <Textarea.Input asChild> <textbox BackgroundTransparency={1} BorderSizePixel={0} ClearTextOnFocus={false} PlaceholderColor3={theme.colors.textSecondary} PlaceholderText="What worked well? What felt rough?" Size={UDim2.fromScale(1, 1)} Text="" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextWrapped TextXAlignment={Enum.TextXAlignment.Left} TextYAlignment={Enum.TextYAlignment.Top} > <uipadding PaddingBottom={new UDim(0, theme.space[10])} PaddingLeft={new UDim(0, theme.space[12])} PaddingRight={new UDim(0, theme.space[12])} PaddingTop={new UDim(0, theme.space[10])} /> </textbox> </Textarea.Input> </frame>
<Textarea.Description asChild> <Text BackgroundTransparency={1} LayoutOrder={2} Size={UDim2.fromOffset(280, 16)} Text="Visible to the whole team." TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} /> </Textarea.Description> </frame> </Textarea.Root>
<frame BackgroundTransparency={1} LayoutOrder={2} Size={UDim2.fromOffset(280, 46)}> <textbutton {...(mergeGuiProps(buttonRecipe({ intent: "primary", size: "sm" }, theme), { AnchorPoint: new Vector2(1, 1), Position: UDim2.fromScale(1, 1), Size: UDim2.fromOffset(130, 36), Text: "Send feedback", TextSize: theme.typography.labelSm.textSize, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> </textbutton> </frame> </frame> );}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
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.
Textarea anatomy
<Textarea.Root> <Textarea.Label /> <Textarea.Input /> <Textarea.Description /> <Textarea.Message /></Textarea.Root>| Part | Required | Responsibility |
|---|---|---|
Textarea.Root | yes | Owns the value, commit callback, shared state, and the auto-resize bounds. |
Textarea.Input | yes | The multi-line TextBox that renders the value, reports changes, and resizes to fit. |
Textarea.Label | no | A textbutton that focuses the input when activated. |
Textarea.Description | no | A static textlabel for helper text. |
Textarea.Message | no | A textlabel for validation text that recolors when invalid is set. |
Examples
Basic labeled textarea
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 dims alongside a disabled field. Auto-resize is on by default, so the input’s height is driven for you; the Size you pass supplies the width and an initial height.
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
autoResize is on by default; minRows and maxRows set the bounds. Here a note composer starts at two rows and grows as the player types — from wrapped lines as well as explicit newlines — until it hits six rows, after which the height stops growing. The primitive only rewrites the height, keeping your width, so give the input a fixed pixel width and let the field own the rest.
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. If you need the numbers without mounting anything (reserving space in a fixed layout, sizing a sibling), call resolveTextareaHeight with the same options.
Change vs commit
onValueChange fires on every text change; onValueCommit fires once when editing ends and the TextBox loses focus. Use change for live UI — previews, counters, dirty indicators — 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 { 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. 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 { 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 shows up in the box, and the counter derives straight from the same state — no second source of truth. The counter recolors as the player approaches the cap.
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
Textarea.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. The underlying TextBox renders with MultiLine and TextWrapped enabled, top-aligned text, and ClearTextOnFocus off.
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 ends editing by clicking away or moving focus — 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.
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-text measurement (
TextBounds.Ydivided by the line height, rounded up), floored at 1, then clamped between the bounds:minRowsdefaults to3(floored at 1), andmaxRows, when set, is raised to at leastminRows. PastmaxRowsthe field stops growing and keeps that fixed height. - Line height defaults to
ceil(TextSize × 1.2)unless you pass an explicitlineHeightonInput. - Vertical padding is summed from the input’s
UIPaddingchildren (offsets plus scale resolved against the box’s absolute height), falling back to14when none contribute.
Each measurement also re-runs on a deferred frame so wrapped TextBounds that settle after the change are picked up. The resize writes UDim2.fromOffset(currentWidth, height) — it preserves your X offset but replaces any scale-based sizing, so size the input with pixel offsets when auto-resize is on. Disabled and read-only inputs still re-measure, so an externally updated value keeps the height correct. Set autoResize={false} to keep a fixed height and size the input yourself.
The height math is exposed as the pure function resolveTextareaHeight(text, options) — the same clamping given minRows, maxRows, lineHeight, and optional verticalPadding/measuredRows — if you need to compute a layout without mounting the component.
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 (still re-running auto-resize), so stray platform input cannot desync the field. They differ in interaction — disabled also clears Active/Selectable, makes the text non-editable, dims the input text, and suppresses the commit callback on focus loss, while readOnly only drops TextEditable, keeping the field selectable and focusable. Input can also set disabled/readOnly locally, which combine with Root’s state via OR.
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, Textarea.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
Textarea.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 multi-line textbox with no size, placeholder, or padding 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. The auto-resize and state wiring are unaffected either way, and all four parts render children, so a uipadding attaches directly.
With autoResize on (the default), the primitive rewrites the input’s Size to an offset height on mount and after every change, keeping only your X offset. Size the input’s width in pixels, treat the height you pass as an initial value, and put the field in a layout that reflows — or set autoResize={false} and own the height yourself.
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, MultiLine/TextWrapped flags, editability, 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, focus, or auto-resize.
API reference
Textarea.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). 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. |
| autoResize | boolean | Grows the input height to fit its content within the row bounds. Defaults to true. |
| minRows | number | Minimum visible rows. Floored at 1. Defaults to 3. |
| maxRows | number | Maximum visible rows before the height stops growing. Raised to at least minRows when set. Unbounded by default. |
| children | React.ReactNode | The field parts. |
Textarea.Input
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge input behavior onto the single child element instead of rendering the default multi-line textbox. 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. |
| lineHeight | number | Explicit per-row pixel height used by auto-resize. Defaults to ceil(TextSize × 1.2). |
| children | React.ReactElement | The textbox element to render. Required when asChild is set. |
Textarea.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. |
Textarea.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. |
Textarea.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. |