Textarea
A multi-line text field on a native <textarea>. It is Input's shape minus the
control box: nothing sits inside a textarea for a wrapper to hold, so the border, ring and
invalid tint draw on the element itself. The model is a plain string written through on
every keystroke, and the visible element is the form control.
Import
import { Textarea } from '@sigx/zero/textarea';
Textarea is a compound: Textarea.Root, Textarea.Label, Textarea.Textarea. It is also
re-exported from the @sigx/zero root, together with textareaAnatomy and
useTextareaContext.
Usage
import { component } from 'sigx';
import { Textarea } from '@sigx/zero/textarea';
const Profile = component(({ signal }) => {
const state = signal({ bio: '' });
return () => (
<Textarea.Root model={() => state.bio} name="bio" rows={4} maxlength={280}>
<Textarea.Label>Bio</Textarea.Label>
<Textarea.Textarea placeholder="Tell us about yourself" />
</Textarea.Root>
);
});
model={() => state.bio} binds the text both ways — every input event writes state.bio,
and writing state.bio updates the field. Leave the model off and pass defaultValue to keep
the state inside the component; valueChange fires either way. See
Models.
Inside a Field
<Field.Root required invalid={!!state.error}>
<Field.Label>Message</Field.Label>
<Textarea.Root model={() => state.message} name="message" rows={6}>
<Textarea.Textarea />
</Textarea.Root>
<Field.Description>Markdown is supported.</Field.Description>
<Field.Error>{state.error}</Field.Error>
</Field.Root>
Inside a Field.Root the <textarea> adopts the field's
control id, its disabled / invalid / required flags and its aria-describedby, so
Textarea.Label becomes optional — the Field.Label names it. Standalone, the component
mints its own ids and Textarea.Label is the label.
Rows and sizing
<Textarea.Root model={() => state.notes} rows={10}>
rows passes straight through to the element, and whether a fixed-size box is
user-resizable is the design system's call (resize: vertical is the usual choice).
Autosize
<Textarea.Root model={() => state.draft} minRows={1} maxRows={8}>
<Textarea.Label visuallyHidden>Message</Textarea.Label>
<Textarea.Textarea placeholder="Write a message…" />
</Textarea.Root>
minRows / maxRows turn autosizing on — either one does; minRows defaults to 1 and no
maxRows is unbounded. The box grows with its content, soft wraps included, never below
minRows lines, and scrolls past maxRows. While it is on, the element's rows follows
minRows rather than the rows prop.
CSS does the growing, so the box is right before hydration and a design system writes none of
it. The textarea part renders data-autosize and the bounds as --textarea-min-rows /
--textarea-max-rows, and a rule in @sigx/zero/css (@layer zero.structure) applies
field-sizing: content, min- / max-block-size in lh units, and resize: none — a manual
resize would switch the growth off. The runtime half,
createAutosize, measures the block padding and border a
border-box element's bounds must add (--textarea-block-chrome), and on an engine without
field-sizing measures scrollHeight and writes the height inline — on input, on a model
write from outside (a composer clearing after send), on a form reset and on a width change.
For design-system authors, autosize is declared by the anatomy (PartSpec.autosize on the
textarea part) as a presentation request, not a flag: expectAnatomy fails data-autosize
on any part that does not declare it, and an app's own data-autosize throws like any
contract-owned data-*.
A composer: keys, ARIA and the element
Textarea.Textarea forwards the native onKeydown, onKeyup, onBeforeinput, onInput,
onCompositionstart, onCompositionend, onFocus and onBlur to the <textarea>, so
preventDefault() works on the control itself; onInput runs after the model has taken the
new value. It forwards aria-*, data-*, title and role too — a combobox-style
composer's ARIA — but not id or aria-invalid, which belong to the form contract, and an
app aria-describedby joins the Field's. Its ref receives a TextareaHandle —
{ element, focus() } — for the caret and the selection.
import { Textarea, type TextareaHandle } from '@sigx/zero/textarea';
let composer: TextareaHandle | null = null;
<Textarea.Root model={() => state.draft} minRows={1} maxRows={8}>
<Textarea.Label visuallyHidden>Message</Textarea.Label>
<Textarea.Textarea
role="combobox"
aria-expanded={suggestions().length > 0}
aria-controls="suggestions"
onKeydown={(e) => {
// Enter sends; Shift+Enter (and Enter mid-composition) is a line break.
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
send();
}
}}
ref={(h) => { composer = h; }}
/>
</Textarea.Root>
composer?.element?.setSelectionRange(caret, caret);
Anatomy
| Part | Element | States | Flags | Notes |
|---|---|---|---|---|
root | div | — | disabled, invalid, required, readonly | Carries the variant axes. |
label | label | — | disabled, invalid, required | for the textarea's id. Offers visuallyHidden. Inside root. |
textarea | textarea | — | disabled, invalid, required, readonly, focus-visible | The native element; carries name, rows, aria-invalid and aria-describedby, and data-autosize with --textarea-min-rows / --textarea-max-rows while autosizing. The chrome draws here. Inside root. |
Every part carries data-scope="textarea" and data-part="<part>". There are no machine
states, only flags. Where Input has a control box as the
seam for something to sit beside the text, a textarea's scrollbar and resize handle belong to
the element itself, so a wrapper would be chrome with nothing to wrap: the focus-visible
flag, the radius-field and size token hints all live on the textarea part. See
The anatomy contract.
There is no hidden-input part either: a <textarea> is a form control and carries its own
name, so it posts pre-hydration without a mirror.
Props
Textarea.Root
| Prop | Type | Default | Description |
|---|---|---|---|
model | string | — | Two-way binding of the text. |
defaultValue | string | '' | Initial value when uncontrolled. |
valueChange | event (value: string) | — | Fires on every change of the value. |
name | string | — | Form field name, rendered on the <textarea>. |
autocomplete | string | — | Native autofill hint — street-address, off, … |
maxlength | number | — | Native maxlength. |
rows | number | — | Native rows; the element's default applies when unset. Ignored while autosizing. |
minRows | number | 1 (when maxRows is set) | Autosize floor in lines; setting it turns autosizing on. |
maxRows | number | — | Autosize ceiling in lines, then the box scrolls; setting it turns autosizing on. |
required | boolean | false | Renders required and data-required; a wrapping Field's required also applies. |
invalid | boolean | false | Renders aria-invalid and data-invalid; a wrapping Field's invalid also applies. |
readonly | boolean | false | Renders readonly and data-readonly. |
disabled | boolean | false | Renders disabled and data-disabled; a wrapping Field's disabled also applies. |
color / size / variant / axes / mods | design-system vocabulary | — | The variant axes, rendered as data-* on root. |
class | string | — | Extra classes on the root element. |
Textarea.Label
class, visuallyHidden (boolean, renders data-visually-hidden) and the forwarded HTML
attributes except id. Renders the label part around its children.
Textarea.Textarea
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | — | Native placeholder text. |
onKeydown / onKeyup | (e: KeyboardEvent) => void | — | Forwarded to the <textarea>. |
onBeforeinput | (e: InputEvent) => void | — | Forwarded; preventDefault() cancels the edit. |
onInput | (e: Event) => void | — | Runs after the model has taken the new value. |
onCompositionstart / onCompositionend | (e: CompositionEvent) => void | — | IME composition, forwarded. |
onFocus / onBlur | (e: FocusEvent) => void | — | Compose with the part's focus-visible tracking. |
aria-* / data-* / title / role | HTML attributes | — | Forwarded. Not id or aria-invalid; aria-describedby joins the Field's. |
ref | (handle: TextareaHandle) => void | — | Receives { element, focus() }. |
class | string | — | Extra classes on the <textarea>. |
name, autocomplete, maxlength, rows, the flags and the ids all come from
Textarea.Root through context; the element part takes only what is specific to it.
Keyboard
The platform's: multi-line text editing, Tab in and out. Enter inserts a newline rather than
submitting a form, as a native <textarea> does. Zero adds nothing on top.
In the shipped design systems
Both @sigx/zero-basic and @sigx/zero-daisyui wire color (the eight recommended roles)
and size (xs–xl) on textarea, so <Textarea.Root color="accent" size="sm"> is styled
in both. Neither wires a variant or any mods on the scope; under a design system's
/register import those props are therefore absent. See
Typed vocabulary.
