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#

TSX
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#

TSX
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#

TSX
<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#

TSX
<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#

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

TSX
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#

PartElementStatesFlagsNotes
rootdiv—disabled, invalid, required, readonlyCarries the variant axes.
labellabel—disabled, invalid, requiredfor the textarea's id. Offers visuallyHidden. Inside root.
textareatextarea—disabled, invalid, required, readonly, focus-visibleThe 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#

PropTypeDefaultDescription
modelstring—Two-way binding of the text.
defaultValuestring''Initial value when uncontrolled.
valueChangeevent (value: string)—Fires on every change of the value.
namestring—Form field name, rendered on the <textarea>.
autocompletestring—Native autofill hint — street-address, off, …
maxlengthnumber—Native maxlength.
rowsnumber—Native rows; the element's default applies when unset. Ignored while autosizing.
minRowsnumber1 (when maxRows is set)Autosize floor in lines; setting it turns autosizing on.
maxRowsnumber—Autosize ceiling in lines, then the box scrolls; setting it turns autosizing on.
requiredbooleanfalseRenders required and data-required; a wrapping Field's required also applies.
invalidbooleanfalseRenders aria-invalid and data-invalid; a wrapping Field's invalid also applies.
readonlybooleanfalseRenders readonly and data-readonly.
disabledbooleanfalseRenders disabled and data-disabled; a wrapping Field's disabled also applies.
color / size / variant / axes / modsdesign-system vocabulary—The variant axes, rendered as data-* on root.
classstring—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#

PropTypeDefaultDescription
placeholderstring—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 / roleHTML attributes—Forwarded. Not id or aria-invalid; aria-describedby joins the Field's.
ref(handle: TextareaHandle) => void—Receives { element, focus() }.
classstring—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.

Input · Field