# Textarea

> The one recipe in the registry that declares no height — because the primitive grows it line by line.

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

```bash
npx facet-rbxts add textarea
```

Copies `ui/textarea.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0` and `@lattice-ui/react-textarea@^0.8.0`.

```tsx
import {
  Textarea,
  TextareaDescription,
  TextareaInput,
  TextareaLabel,
} from "../shared/ui/textarea";

<Textarea maxRows={5} minRows={3} onValueCommit={submitReport}>
  <TextareaLabel Text="What happened?" />
  <TextareaInput />
  <TextareaDescription Text="A moderator reads this — keep it to what you saw." />
</Textarea>
```

_Interactive preview: Three rows to start, five at most. Type into it and the box grows a line at a time._

## The parts

| Part | Renders | Classes |
| --- | --- | --- |
| `Textarea` | `Frame` | `flex-col gap-2 w-full h-fit` |
| `TextareaInput` | `TextBox` | `w-full rounded-md border border-input px-3 py-2 text-left text-sm font-normal text-foreground placeholder-muted-foreground focus:border-ring` |
| `TextareaLabel` | `TextButton` | `w-full h-fit text-left text-sm font-medium text-foreground` |
| `TextareaDescription` | `TextLabel` | `w-full h-fit whitespace-normal text-left text-xs font-normal text-muted-foreground` |

Four parts, not five — there is no `TextareaMessage`. A multi-line box that needs a validation line
can reach for [`TextFieldMessage`](https://docs.astra-void.xyz/facet/components/text-field.md), or the file is yours to add one.

Renders a `TextBox`. Unknown props forward onto it and are type-checked against it, so a prop `TextBox` does not accept is a compile error. The primitive owns `Size` on the Y axis, which it grows with the text, so values you pass for it are ignored.

## The input declares no height

```tsx
input: fv(
  "w-full rounded-md border border-input px-3 py-2 …",
),
```

Rule 1 of the [conventions](https://docs.astra-void.xyz/facet/guides/component-conventions.md#1-declare-both-axes-always) is
declare both axes, always — a Roblox instance with an unresolved axis renders at zero and takes the
row above it with it. This recipe breaks that rule on purpose, and it is the only one that does.

`Textarea.Input` owns `Size.Y`. It measures the wrapped text and sets the height between `minRows`
and `maxRows` on every keystroke. A height class here would not lose an argument with the primitive —
it would be overwritten a frame later, which is worse, because the first render would look right.

> **This is the same rule as the slider's missing flex**
>
> Where a Lattice primitive owns a property, the recipe stays off it. [Slider](https://docs.astra-void.xyz/facet/components/slider.md#the-track-has-no-flex--and-that-is-the-point)
> omits `flex-*` from its track for exactly this reason, and [Switch](https://docs.astra-void.xyz/facet/components/switch.md#the-thumb-has-no-position)
> omits position from its thumb. A primitive that owns a property is as load-bearing as the
> `UIListLayout` that owns one.

## Props

### `Textarea`

| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | Controlled value. Pass it with onValueChange. |
| `defaultValue` | `string` | Uncontrolled starting text. |
| `onValueChange` | `(value: string) => void` | Fires on every keystroke. |
| `onValueCommit` | `(value: string) => void` | Fires when the box loses focus, with the text as it stands. |
| `autoResize` | `boolean` | Grow with the text. On by default; the box then sizes between minRows and maxRows. |
| `minRows` | `number` | Floor for the grown height. |
| `maxRows` | `number` | Ceiling. Past it the box scrolls instead. |
| `disabled` | `boolean` | Blocks focus and typing. |
| `readOnly` | `boolean` | Focusable, not editable. |
| `invalid` | `boolean` | Carries the state into Lattice's context. The red border comes from the input's own invalid. |
| `className` | `ClassName` | Threaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |

### `TextareaInput`

| Prop | Type | Description |
| --- | --- | --- |
| `invalid` | `boolean` | Adds border-destructive. |
| `disabled` | `boolean` | Adds opacity-50. |
| `className` | `ClassName` | Threaded into the input recipe's className slot inside the component. |

`invalid` is stated on the root **and** on the input, for
[the reason Text field spells out](https://docs.astra-void.xyz/facet/components/text-field.md#invalid-is-stated-twice-and-that-is-not-a-bug):
nothing inherits, so the context half and the appearance half are two different props on two
different instances.

## `autoResize` off

With `autoResize={false}` the primitive stops touching `Size.Y`, and the recipe's missing height
becomes the problem rule 1 warns about: give the input one.

This is the rare case where a call-site `className` is the right place to do it. A class written
there is resolved at that call site and arrives as instance properties, which the component's own
recipe then overwrites *for the properties it also names* — and this recipe names no height, so
`<TextareaInput className="h-24" />` survives. Anything the recipe does state, like `w-full`, does
not. The mechanism, and why it reads backwards from `cn`'s own composition rules, is in
[overriding from the call site](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site).
