---
title: Textarea
description: Multi-line text input with resize control
visibility: guest
draft: false
---

# Textarea

A textarea component with label, error handling, hint support, and configurable rows.

## Basic Usage

:::preview{title="A labeled textarea"}
<x-wirekit::textarea label="Bio" name="bio" wire:model="bio" />
:::

## Custom Rows

:::preview{title="A taller field via rows"}
<x-wirekit::textarea label="Description" name="description" rows="6" />
:::

## Disable Resize

:::preview{title="Resize disabled"}
<x-wirekit::textarea label="Comment" name="comment" :resize="false" />
:::

## Auto-size

Set `rows="auto"` to grow the textarea with its content as the user types — no JavaScript needed (it uses the CSS `field-sizing: content` feature, supported across the WireKit browser baseline). The numeric `rows` still acts as the minimum height. Auto-size grows the **height** only — the width follows the container, as in any real layout (here a fixed-width form column).

:::preview{title="Grows with content"}
<x-wirekit::textarea style="width: 28rem; max-width: 100%" label="Notes" name="notes" rows="auto" placeholder="Type a few lines — the field grows to fit…" />
:::

## With Error

:::preview{title="Error state"}
<x-wirekit::textarea
    label="Message"
    name="message"
    error="Message is required."
/>
:::

Errors from Laravel's validation bag are shown automatically when `name` matches a field.

## With Success

Confirm a valid field with a green border and an optional message — the mirror of the error state, but valid (no `aria-invalid`). `error` wins when both are set.

:::preview{title="Success / valid state"}
<x-wirekit::textarea label="Bio" name="bio" success="Saved" />
:::

## With Hint

:::preview{title="With a hint below the field"}
<x-wirekit::textarea
    label="Bio"
    name="bio"
    hint="Maximum 500 characters."
    rows="4"
/>
:::

## Character Counter

Combine the textarea with [Alpine.js](https://alpinejs.dev/) to show a live character count with min/max validation:

:::preview{title="Live character counter with min/max"}
<div x-data="{ text: '', max: 500, min: 10 }">
    <x-wirekit::textarea
        label="Bio"
        name="bio-counter"
        rows="4"
        x-model="text"
        maxlength="500"
        placeholder="Tell us about yourself..."
    />
    <div style="display: flex; justify-content: space-between; align-items: center; margin-top: 0.375rem; font-size: var(--text-wk-sm); gap: 0.5rem;">
        <div style="min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
            <span x-cloak x-show="text.length > 0 && text.length < min" style="color: var(--color-wk-danger-text);">
                Minimum 10 characters required.
            </span>
            <span x-cloak x-show="text.length >= max" style="color: var(--color-wk-danger-text);">
                Character limit reached.
            </span>
        </div>
        <span style="color: var(--color-wk-text-muted); flex-shrink: 0; white-space: nowrap;" x-text="`${text.length}/${max}`">0/500</span>
    </div>
</div>
:::

The counter is not part of the textarea component itself — it's a lightweight Alpine.js wrapper you add in your template. This gives you full control over the counter position, validation messages, and threshold logic.

## Width

Like all WireKit form components, the textarea fills its container (`w-full`). Control the width via the parent element:

:::preview{title="Constrained width via parent container"}
<div style="max-width: 28rem;">
    <x-wirekit::textarea label="Bio" name="bio-width-demo" rows="4" placeholder="Parent is max-w-md (28rem) — textarea fills it." />
</div>
:::

:::source{language="blade"}
<x-wirekit::textarea label="Bio" name="bio-width-demo" rows="4" placeholder="Parent is max-w-md (28rem) — textarea fills it." />
:::

See [Input — Width](/components/input#width) for more layout examples (grid columns, mixed widths).

## Optimistic UI

Pass the name of the Livewire method the field should call and the text is sent when you leave the field, shown as saving while it goes:

```blade
<x-wirekit::textarea
    name="bio"
    label="Bio"
    optimistic="saveBio"
>{{ $bio }}</x-wirekit::textarea>
```

Load `wirekit-optimistic.js` alongside whichever bundle you already use — below it, in your layout:

```blade
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}"></script>
```

### Try it

The demo below runs the real path: the change shows immediately, the outline says it is
provisional, and the server's answer either confirms it silently or takes it back.

:::preview{title="Optimistic textarea — accepted, refused, and a slow answer"}
<livewire:demos.optimistic-host>
<x-wirekit::stack gap="lg" style="max-width: 26rem;">
    <x-wirekit::textarea name="opt-accept" label="Accepted — the confirmation is silent" optimistic="demoAccept">Writes about type, mostly.</x-wirekit::textarea>
    <x-wirekit::textarea name="opt-reject" label="Refused — your text stays, and you are told it did not save" optimistic="demoReject">Writes about type, mostly.</x-wirekit::textarea>
    <x-wirekit::textarea name="opt-slow" label="Slow answer — the dashed outline is the provisional state" optimistic="demoSlow">Writes about type, mostly.</x-wirekit::textarea>
</x-wirekit::stack>
</livewire:demos.optimistic-host>
:::

:::source{language="blade"}
{{-- In your app there is no host: your own Livewire component owns the method. --}}
<x-wirekit::textarea
    name="bio"
    label="Bio"
    optimistic="saveBio"
>{{ $bio }}</x-wirekit::textarea>
:::

The `<livewire:demos.…>` wrapper above exists only on this site — it supplies the demo
methods so the page can show a real round trip. The block under it is what you write.

**A refusal does not take your text back, and that is the important part.** Everywhere else in WireKit an optimistic update that fails puts the old value back — for a toggle or a select that costs you nothing, because the old value is simply the other choice. Here the old value belongs to the server and the new one is what you just wrote, so putting it back would delete your work because a save failed. No editor behaves that way, and neither does this.

Instead the text stays exactly where it is, and you are told two things: that it did not save, and that your text is still there. The second one is the question you actually have.

The field is **not** marked invalid. `aria-invalid` means *this value is wrong*, and a save that failed on the network says nothing about the value — it may be perfectly good and simply unsaved.

::: info What a screen reader hears
Leaving the field announces once, hedged — "Saving" — so the state is audible as provisional. **Confirmation is silent**: what was announced is what happened.

A refusal announces once more, and the message carries the reassurance: *could not save, your text is still here*.

Where the field already shows a validation message, the layer stays **silent** and leaves that message to speak: it tells you what to fix, "could not save" does not.

An aborted request announces nothing at all — nothing was refused.

Focus stays exactly where you put it. An undo arrives on the server's schedule, and moving focus then would take you out of your place for a reason you could not predict.
:::

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | string\|null | `null` | Label text above the textarea |
| `hideLabel` | bool | `false` | Render the label visually hidden (`sr-only`) but keep it for assistive tech — for a compact field in a toolbar / header |
| `hint` | string\|null | `null` | Help text below the textarea |
| `reserveMessage` | bool | `false` | Keep the message line's height even when there is no message — see [Fields in a row](/components/input#fields-in-a-row) |
| `error` | string\|null | `null` | Error message (overrides `$errors` bag) |
| `success` | string\|bool\|null | `null` | Valid-state confirmation — string shows a green message, `true` shows just the green border. `error` wins. |
| `size` | string | `'md'` | `sm`, `md`, `lg` |
| `rows` | int\|string | `3` | Number of visible rows, or `'auto'` to grow with content (min height stays at the numeric fallback) |
| `optimistic` | `string\|null` | `null` | Livewire method to call when you leave the field, showing the new text as saving. A refusal **keeps your text**. See [Optimistic UI](#optimistic-ui). |
| `optimisticArgs` | `array` | `[]` | Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to. |
| `resize` | bool | `true` | Allow vertical resizing |
| `scope` | string\|null | `null` | Scoped personalization name |

## Accessibility

The textarea component follows the same accessibility patterns as [Input](/components/input#accessibility).

- **Label pairing** — When the `label` prop is set, a `<x-wirekit::label>` is rendered with `for` pointing to the textarea's `id`. The `id` is auto-generated from the `name` attribute.
- **Error states** — When an error is present, the textarea sets `aria-invalid="true"` and `aria-describedby="{id}-error"` to link the error message for screen readers.
- **Hint text** — When `hint` is set and no error is active, the hint is linked via `aria-describedby="{id}-hint"`.
- **`:user-invalid` styling** — Native HTML5 constraints (`required`, `minlength`, `maxlength`) trigger a red border after user interaction, using `:user-invalid` (not `:invalid`).
- **Character counter** — When adding a character counter (see [example above](#character-counter)), ensure the counter text is perceivable. The Alpine.js pattern uses `x-text` which updates the DOM live — screen readers pick up changes naturally. For more explicit announcements, wrap the counter in an `aria-live="polite"` region.
- **Disabled state** — Uses the native `disabled` attribute with visual muting.
- **Focus ring** — Uses `focus-visible` for keyboard-only visibility.

## Keyboard Interaction

| Key | Action |
|-----|--------|
| `Tab` | Move focus into the textarea (within the textarea, `Tab` is treated as focus-out, not as an indent character) |
| `Enter` | Insert a line break |
| `Home` / `End` | Move caret to start / end of the current line |
| `Ctrl+Home` / `Ctrl+End` | Move caret to top / bottom of the textarea |
| `Shift+Arrow` | Extend selection |

## Pitfalls

- **Don't use textarea for code input.** Reach for `<x-wirekit::code-block>` (read-only) or a real editor (Monaco / CodeMirror) — textarea has no syntax highlighting, no tab-indent, and no line-numbering.
- **Don't bind `wire:model.live` for prose.** Each keystroke round-trips. Use `wire:model.blur`.

## Design Tokens

| Token | Used for |
| --- | --- |
| `--font-wk-sans` | Body font family |
| `--font-wk-letter-spacing` | Letter spacing |
| `--text-wk-sm` / `--text-wk-md` / `--text-wk-lg` | Font size per `size` prop |
| `--color-wk-text` | Textarea text |
| `--color-wk-text-muted` | Counter / hint text |
| `--color-wk-text-placeholder` | Placeholder text |
| `--color-wk-bg-input` | Textarea background |
| `--color-wk-border-strong` | Default border |
| `--color-wk-border-strong-hover` | Hover border |
| `--color-wk-border-error` | Error-state border |
| `--color-wk-danger` / `--color-wk-danger-text` | Error message |
| `--color-wk-ring` / `--color-wk-ring-offset` | Focus ring color + offset |
| `--ring-wk-width` / `--ring-wk-offset` | Focus ring geometry |
| `--border-wk-width` | Border width |
| `--radius-wk-sm` / `--radius-wk-md` | Border radius |
| `--shadow-wk-sm` | Subtle shadow |
| `--padding-wk-x-sm` / `--padding-wk-x-md` / `--padding-wk-x-lg` | Horizontal padding |
| `--padding-wk-y-sm` / `--padding-wk-y-md` / `--padding-wk-y-lg` | Vertical padding |
| `--opacity-wk-disabled` | Disabled visual weight |
| `--transition-wk-duration` / `--transition-wk-easing` | Hover / focus transition |

## See Also

- [Inline Edit](/components/inline-edit) — edit this value in place, with an explicit confirm step
