# Textarea

> Multi-line text input primitive that owns the value, separates change from commit, auto-resizes to its content within row bounds, and wires label, description, and message parts together.

Source: https://docs.astra-void.xyz/lattice-ui/components/textarea/

`@lattice-ui/react-textarea` · Stable direction · import `Textarea` · depends on `runtime`, `focus`, `motion`

Textarea 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](https://docs.astra-void.xyz/lattice-ui/components/text-field.md) is the sibling primitive.

## Preview

The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive.

_Interactive preview._

## Import

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

The package also exports the pure height helper used internally:

```ts
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.

```tsx title="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.

```tsx title="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

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

```tsx title="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. 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.

```tsx title="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. `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.

```tsx title="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 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.

```tsx title="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

`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.Y` divided by the line height, rounded up), floored at 1, then clamped between the bounds: `minRows` defaults to `3` (floored at 1), and `maxRows`, when set, is raised to at least `minRows`. Past `maxRows` the field stops growing and keeps that fixed height.
- **Line height** defaults to `ceil(TextSize × 1.2)` unless you pass an explicit `lineHeight` on `Input`.
- **Vertical padding** is summed from the input's `UIPadding` children (offsets plus scale resolved against the box's absolute height), falling back to `14` when 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.

> **Default text and visuals**
>
> 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.

> **Auto-resize owns the height**
>
> 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.

> **Commit fires on every focus loss**
>
> `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.

> **Input children must be a TextBox**
>
> 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. |

## Related

- [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md)
- [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
- [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md)
