Textarea
Multiline text input with invalid, disabled, dark, RTL, and responsive states. Inside a <x-ui.field name> (or with its own name) it self-wires old() repopulation and aria-invalid/aria-describedby from the shared $errors bag.
Preview
<x-ui.textarea placeholder="{{ __('Type your message here.') }}" aria-label="{{ __('Message') }}" class="max-w-sm" />
Installation
php artisan ui:add textarea
Registry contract
php artisan ui:add textarea
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/textarea.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Textarea (`textarea`)
Multiline text input with invalid, disabled, dark, RTL, and responsive states. Inside a <x-ui.field name> (or with its own name) it self-wires old() repopulation and aria-invalid/aria-describedby from the shared $errors bag.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add textarea
```
## Usage
```blade
<x-ui.textarea placeholder="{{ __('Type your message here.') }}" aria-label="{{ __('Message') }}" class="max-w-sm" />
```
## Props
- `rows` (int, default `4`) — Visible row count before scrolling.
- `invalid` (bool, default `false`) — Forces the invalid visual state independent of the shared error bag.
- `disabled` (bool, default `false`) — Disables the field and excludes it from submission.
- `autogrow` (bool, default `false`) — Grow with the content instead of scrolling; rows is the starting height. Uses CSS field-sizing: content, with an Alpine fallback that sets the height on input in browsers without it.
- `maxRows` (int|null, default `null`) — With autogrow: the row count at which the field stops growing and scrolls. Null grows without a limit.
## Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- Collecting multiple lines of free text, such as a comment, message or description.
- Needing automatic old() repopulation and aria-invalid/aria-describedby wiring off Laravel's shared $errors bag.
## Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- A single line of text; use input instead.
- The text should highlight @-mentions as the user types; use mention instead.
## Anti-patterns
- Using freeform entry for a bounded answer set
## Rules
- Use the `<brok:textarea>` tag (or `<x-ui.textarea>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/textarea
- Registry JSON (files, props, contract): https://brokui.dev/r/open/textarea.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- Autogrow: a one-row task title that wraps and grows with its text, up to
four rows, then scrolls. --}}
<x-ui.textarea
autogrow
rows="1"
max-rows="4"
aria-label="{{ __('Task title') }}"
placeholder="{{ __('Task title') }}"
class="max-w-sm"
>{{ __('Prepare the quarterly budget review with finance and send the draft to the board before Friday') }}</x-ui.textarea>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| rows | int | 4 | Visible row count before scrolling. |
| invalid | bool | false | Forces the invalid visual state independent of the shared error bag. |
| disabled | bool | false | Disables the field and excludes it from submission. |
| autogrow | bool | false | Grow with the content instead of scrolling; rows is the starting height. Uses CSS field-sizing: content, with an Alpine fallback that sets the height on input in browsers without it. |
| maxRows | int | null | null | With autogrow: the row count at which the field stops growing and scrolls. Null grows without a limit. |
Slots
default— Initial content, used when there is no flashed old() value.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Inside a wrapping field (or with its own name attribute) self-wires old() repopulation, aria-invalid and aria-describedby off the shared $errors bag.
- A flashed old() value wins over the slotted content after a failed redirect-back.
- With autogrow the height follows the content from rows up to max-rows lines (data-autogrow, field-sizing: content, no resize handle); a long one-row title wraps instead of being cut.
- Forwards native textarea attributes and preserves placeholder, disabled, required, rows, and aria-* semantics.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Collect unpredictable freeform information.
Use when
- Use when users must enter freeform information that cannot be reliably selected from a list.
- Collecting multiple lines of free text, such as a comment, message or description.
- Needing automatic old() repopulation and aria-invalid/aria-describedby wiring off Laravel's shared $errors bag.
Avoid when
- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- A single line of text; use input instead.
- The text should highlight @-mentions as the user types; use mention instead.
Use instead
- Radio or checkbox for bounded choices
- Select or combobox for known options
Anti-patterns
- Using freeform entry for a bounded answer set
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- aria-required, aria-invalid and aria-describedby are set automatically from validation state and any inherited hint or error.
- Pair with label (or a wrapping field) so the control has an accessible name.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="textarea-{{ $record->id }}">
<x-ui.textarea placeholder="{{ __('Type your message here.') }}" aria-label="{{ __('Message') }}" class="max-w-sm" />
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:textarea
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- Inherited from a wrapping field with a name (see input for the rationale). --}}
@aware([
'name' => null,
'htmlName' => null,
'hint' => null,
'required' => false,
])
@props([
'rows' => 4,
'invalid' => false,
'disabled' => false,
// Grow with the content instead of scrolling: `rows` becomes the
// starting height. Uses CSS field-sizing: content, with a small script
// fallback for browsers without it.
'autogrow' => false,
// With autogrow: the row count at which it stops growing and scrolls.
'maxRows' => null,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$textarea = $styles['textarea'];
// @aware leaves $name undefined when there is no wrapping field, so normalise it.
$awareName = $name ?? null;
$awareHtmlName = $htmlName ?? null;
// Effective field name: the @aware value from a wrapping field, else an explicit
// `name` attribute on the textarea itself.
$fieldName = $awareName ?? $attributes->get('name');
$resolvedHtmlName = $awareName
? ($awareHtmlName ?? collect(explode('.', $awareName))->reduce(
fn ($carry, $segment, $index) => $index === 0 ? $segment : $carry.'['.$segment.']',
''
))
: $attributes->get('name');
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
$isInvalid = $invalid || $hasBagError;
$state = $isInvalid ? $textarea['states']['invalid'] : $textarea['states']['default'];
$classes = trim($textarea['base'].' '.$state);
// Repopulate after redirect-back: a flashed old($name) wins; otherwise the
// slotted content is used. The slot is already escaped Blade output, so it is
// printed raw; a flashed value is raw user input and is escaped here.
$flashedValue = filled($fieldName) && old($fieldName) !== null ? e((string) old($fieldName)) : null;
$describedBy = collect(preg_split('/\s+/', trim((string) $attributes->get('aria-describedby', ''))) ?: [])
->when(filled($awareName) && filled($hint ?? null), fn ($ids) => $ids->push($fieldName.'-description'))
->when(filled($awareName) && $hasBagError, fn ($ids) => $ids->push($fieldName.'-error'))
->filter()
->unique()
->implode(' ');
$isRequired = (bool) ($required ?? false) || $attributes->has('required');
$resolvedId = $attributes->get('id', filled($fieldName) ? $fieldName : null);
// Autogrow: the height follows the content between `rows` and `maxRows`
// lines (1lh each, plus the py-2 padding and the 1px borders).
$rows = max(1, (int) $rows);
$maxRows = filled($maxRows) ? max($rows, (int) $maxRows) : null;
$autogrowStyle = $autogrow
? 'min-height: max(var(--control-height-md), calc('.$rows.' * 1lh + 1rem + 2px));'
.($maxRows !== null ? ' max-height: calc('.$maxRows.' * 1lh + 1rem + 2px);' : '')
: null;
if ($autogrow) {
$classes .= ' field-sizing-content resize-none overflow-y-auto';
}
@endphp
<textarea
rows="{{ $rows }}"
@if (filled($resolvedHtmlName)) name="{{ $resolvedHtmlName }}" @endif
@if (filled($resolvedId)) id="{{ $resolvedId }}" @endif
data-slot="textarea"
@disabled($disabled)
@required($isRequired)
@if ($isRequired) aria-required="true" @endif
@if ($isInvalid) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
@if ($autogrow)
data-autogrow
style="{{ $autogrowStyle }}"
x-data
x-init="if (!CSS.supports('field-sizing', 'content')) { const fit = () => { $el.style.height = 'auto'; $el.style.height = ($el.scrollHeight + $el.offsetHeight - $el.clientHeight) + 'px' }; fit(); $el.addEventListener('input', fit) }"
@endif
{{ $attributes->except(['name', 'id', 'required', 'aria-required', 'aria-describedby'])->merge(['class' => $classes]) }}
>{!! $flashedValue ?? trim((string) $slot) !!}</textarea>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.5.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0