@lattice-ui/react-textareaStable directionimport Textareadepends on runtime, focus, motionTextarea 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
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
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, 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.
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.
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.
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.
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.
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.
minRowsdefaults to3.maxRows, when set, is raised to at leastminRows. - Line height defaults to
ceil(TextSize × 1.2)unlesslineHeightis set onInput. - Vertical padding is summed from the input’s
UIPaddingchildren, falling back to14.
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.
Every part renders unstyled and carries no copy. Input is a multi-line textbox with no size, placeholder or padding of its own, and 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 parts render children, so a uipadding attaches directly.
With autoResize on, the primitive rewrites the input’s Size on mount and after every change, keeping only your X offset. Size the width in pixels, treat the height as an initial value, and put the field in a layout that reflows — or set autoResize={false}.
onValueCommit is not change detection — it fires whenever editing ends, even on identical text, and only disabled suppresses it. If you persist on commit, dedupe against the last saved value yourself.
With asChild, the Input merges its Text binding, flags, editability and handlers onto your single child, and its ref wiring only accepts a TextBox. 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. |