Textarea
A multi-line text input with integrated label, helper text, character counter, and auto-resize. Form-associated and fully keyboard accessible.
Features
6 Semantic Colors — primary, secondary, info, success, warning, error 5 Variants — solid, flat, bordered, outline, ghost Label Placement — inset (floating-style) or outside 3 Sizes — sm, md, lg Auto Resize — grows vertically with content; no scrollbar Helper & Error Text — descriptive text or validation errors below the field Form-Associated — participates in native form submission Character Counter — live counter with near-limit and at-limit colour feedback
Source Code
View Source Code
ts
import { define, useField, html, inject, live, prop, ref } from '@vielzeug/craft';
import { watch as rippleWatch } from '@vielzeug/ripple';
import type { TextFieldProps } from '../../shared';
import type { VisualVariant } from '../../types';
import { lifecycleSignal, createTextField } from '../../headless';
import { disablableBundle, roundableBundle, sizableBundle, TEXTAREA_SIZE_PRESET, themableBundle } from '../../shared';
import { fieldMixins, forcedColorsFocusMixin, sizeVariantMixin } from '../../styles';
import { FORM_CTX, useFormContext } from '../shared/form-context';
import componentStyles from './textarea.css?inline';
/** Textarea component properties */
export type SgTextareaEvents = {
change: { originalEvent: Event; value: string };
input: { originalEvent: Event; value: string };
};
export type SgTextareaProps = TextFieldProps<Exclude<VisualVariant, 'frost' | 'text'>> & {
/** Allow auto-grow with content */
'auto-resize'?: boolean;
/** Maximum character count; shows a counter when set */
maxlength?: number;
/** Disable a manual resize handle */
'no-resize'?: boolean;
/**
* JS-only callback fired with the inner `<textarea>` element when it mounts,
* and with `null` when it unmounts. Intended for composed components that
* need imperative access to the raw element.
* Set as a JS property: `bitTextarea.ref = (el) => { ... }`.
*/
ref?: ((el: HTMLTextAreaElement | null) => void) | null;
/** Resize direction override */
resize?: 'none' | 'horizontal' | 'both' | 'vertical';
/** Number of visible text rows */
rows?: number;
};
/**
* A multi-line text input with label, helper text, character counter, and auto-resize.
*
* @element sg-textarea
*
* @attr {string} label - Label text
* @attr {string} label-placement - 'inset' | 'outside'
* @attr {string} value - Current value
* @attr {string} placeholder - Placeholder text
* @attr {string} name - Form field name
* @attr {number} rows - Visible row count
* @attr {number} maxlength - Max character count (shows counter)
* @attr {string} helper - Helper text below the textarea
* @attr {string} error - Error message
* @attr {boolean} disabled - Disable interaction
* @attr {boolean} readonly - Read-only mode
* @attr {boolean} required - Required field
* @attr {boolean} no-resize - Disable manual resize
* @attr {boolean} auto-resize - Grow with content
* @attr {string} resize - Resize direction: 'none' | 'horizontal' | 'both' | 'vertical'
* @attr {string} color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
* @attr {string} variant - Visual variant: 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost'
* @attr {string} size - Component size: 'sm' | 'md' | 'lg'
* @attr {string} rounded - Border radius: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
*
* @fires input - Fired on every keystroke with current value. detail: { value: string; originalEvent: Event }
* @fires change - Fired on blur with changed value. detail: { value: string; originalEvent: Event }
*
* @slot helper - Complex helper content
*
* @cssprop --textarea-bg - Background color
* @cssprop --textarea-border-color - Border color
* @cssprop --textarea-placeholder-color - Placeholder text color
* @cssprop --textarea-radius - Border radius
* @cssprop --textarea-padding - Inner padding (block inline)
* @cssprop --textarea-gap - Gap between label and field
* @cssprop --textarea-font-size - Font size
* @cssprop --textarea-min-height - Minimum field height
* @cssprop --textarea-max-height - Maximum field height (none = unlimited)
* @cssprop --textarea-resize - CSS resize direction ('vertical' | 'horizontal' | 'both' | 'none')
* @cssprop --textarea-hover-bg - Field background on hover (flat/ghost variants)
* @cssprop --textarea-hover-border-color - Field border on hover (flat/bordered variants)
* @cssprop --textarea-focus-bg - Field background when focused (flat variant)
* @cssprop --textarea-focus-border-color - Field border when focused (flat variant)
*
* @part wrapper - Outer wrapper element.
* @part label - Label element.
* @part field - Field container.
* @part textarea - The native `<textarea>` element.
*
* @example
* ```html
* <sg-textarea></sg-textarea>
* ```
*/
export const TEXTAREA_TAG = 'sg-textarea' as const;
define<SgTextareaProps, SgTextareaEvents>(TEXTAREA_TAG, {
formAssociated: true,
props: {
...themableBundle,
...sizableBundle,
...disablableBundle,
...roundableBundle,
'auto-resize': prop.bool(false),
error: prop.string(),
fullwidth: prop.bool(false),
helper: prop.string(),
label: prop.string(),
'label-placement': prop.oneOf(['inset', 'outside'] as const, 'inset'),
maxlength: prop.json(undefined as number | undefined),
name: prop.string(),
'no-resize': prop.bool(false),
placeholder: prop.string(),
readonly: prop.bool(false),
ref: prop.json(undefined as ((el: HTMLTextAreaElement | null) => void) | null | undefined),
required: prop.bool(false),
resize: prop.string<'none' | 'both' | 'horizontal' | 'vertical'>(),
rows: prop.json(undefined as number | undefined),
value: prop.string(),
variant: prop.string<'flat' | 'solid' | 'bordered' | 'outline' | 'ghost'>(),
},
setup(props, { bind, emit, onCleanup, onElement, watch }) {
const formCtx = inject(FORM_CTX);
const fCtxProps = useFormContext(bind, props, formCtx);
const textareaRef = ref<HTMLTextAreaElement>();
const autoGrow = () => {
if (!props['auto-resize'].value || !textareaRef.value) return;
const textareaEl = textareaRef.value;
textareaEl.style.height = 'auto';
textareaEl.style.height = `${textareaEl.scrollHeight}px`;
};
const abortSignal = lifecycleSignal(onCleanup);
let _formField: { reportValidity(): void } | null = null;
const tf = createTextField({
disabled: fCtxProps.disabled,
error: props.error,
getFormField: () => _formField,
helper: props.helper,
label: props.label,
labelPlacement: props['label-placement'],
maxLength: props.maxlength,
onBeforeInput: autoGrow,
onChange: (event: Event, value: string) => {
emit('change', { originalEvent: event, value });
},
onInput: (event: Event, value: string) => {
emit('input', { originalEvent: event, value });
},
prefix: 'textarea',
signal: abortSignal,
validateOn: formCtx?.validateOn,
value: props.value,
});
_formField = useField<string>({ disabled: tf.disabled, toFormValue: (v) => v, value: tf.value });
const {
ariaDescribedBy,
ariaErrorMessage,
ariaInvalid,
ariaLabelledBy,
assistiveId,
counter,
errorText,
fieldId: textareaId,
helperText,
labelId,
labelVisible,
} = tf;
onElement(textareaRef, (textareaEl) => {
const unwireEl = tf.wire(textareaEl);
props.ref.value?.(textareaEl);
const sub = rippleWatch(props.ref, (cb) => {
cb?.(textareaEl);
});
const stopLayoutEffect = watch(() => {
textareaEl.style.resize =
props['auto-resize'].value || props['no-resize'].value ? 'none' : props.resize.value || 'vertical';
if (props['auto-resize'].value) {
requestAnimationFrame(autoGrow);
}
});
return () => {
sub.dispose();
props.ref.value?.(null);
unwireEl();
stopLayoutEffect();
};
});
bind({
attr: {
error: () => errorText.value || undefined,
size: fCtxProps.size,
variant: fCtxProps.variant,
},
});
const counterClass = () =>
counter?.value.counterAtLimit
? 'counter at-limit'
: counter?.value.counterNearLimit
? 'counter near-limit'
: 'counter';
const counterHidden = () => !counter;
const counterText = () => counter?.value.counterText.replace(' / ', '/') ?? '';
const helperHidden = () => !errorText.value && !helperText.value;
const helperTextContent = () => errorText.value || helperText.value;
return html`
<div class="textarea-wrapper" part="wrapper">
<label class="label" part="label" for="${textareaId}" id="${labelId}" ?hidden="${() => !labelVisible.value}"
>${props.label}</label
>
<div class="field" part="field">
<textarea
part="textarea"
ref="${textareaRef}"
id="${textareaId}"
:name="${props.name}"
:placeholder="${props.placeholder}"
:rows="${props.rows}"
:maxlength="${props.maxlength}"
?disabled="${props.disabled}"
?readonly="${props.readonly}"
?required="${props.required}"
:value="${live(tf.value)}"
:aria-describedby="${ariaDescribedBy}"
:aria-errormessage="${ariaErrorMessage}"
:aria-invalid="${ariaInvalid}"
:aria-labelledby="${ariaLabelledBy}"></textarea>
</div>
<span class="${counterClass}" aria-live="polite" ?hidden="${counterHidden}">${counterText}</span>
<div id="${assistiveId}" class="helper-text" aria-live="polite" ?hidden="${helperHidden}">
${helperTextContent}
</div>
</div>
`;
},
shadow: { delegatesFocus: true },
styles: [...fieldMixins, sizeVariantMixin(TEXTAREA_SIZE_PRESET), forcedColorsFocusMixin('textarea'), componentStyles],
});Basic Usage
html
<sg-textarea label="Message" placeholder="Write something..."></sg-textarea>Visual Options
Variants
Colors
Sizes
Labels
Inset (Default)
The label floats inside the field as a compact top label.
Outside
The label is placed above the field.
Helper & Error Text
Character Counter
Set maxlength to enable a live character counter. The counter turns amber near the limit and red at the limit.
Auto Resize
Set auto-resize to let the textarea grow vertically with its content. Manual resize is automatically disabled.
Resize Control
Control the resize handle with the resize attribute.
States
API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
label | string | '' | Label text |
label-placement | 'inset' | 'outside' | 'inset' | Label placement |
value | string | '' | Current value |
name | string | '' | Form field name |
placeholder | string | '' | Placeholder text |
rows | number | - | Visible row count (sets minimum height) |
maxlength | number | - | Maximum character count — enables counter when set |
helper | string | '' | Helper text below the field |
error | string | '' | Error message (marks field invalid) |
disabled | boolean | false | Disable the textarea |
readonly | boolean | false | Make the textarea read-only |
required | boolean | false | Mark the field as required |
fullwidth | boolean | false | Expand to full width |
auto-resize | boolean | false | Grow vertically with content |
no-resize | boolean | false | Disable the manual resize handle |
resize | 'none' | 'vertical' | 'horizontal' | 'both' | 'vertical' | Resize direction |
variant | 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'solid' | Visual style variant |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | - | Color theme |
size | 'sm' | 'md' | 'lg' | 'md' | Component size |
rounded | 'none' | 'sm' | 'md' | 'lg' | 'full' | - | Border radius override |
Slots
| Slot | Description |
|---|---|
helper | Complex helper content below the field |
Events
| Event | Detail | Description |
|---|---|---|
input | { value: string, originalEvent: Event } | Fired on every keystroke |
change | { value: string, originalEvent: Event } | Fired when value is committed (on blur) |
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--textarea-bg | Background color | Variant-dependent |
--textarea-border-color | Border color | Variant-dependent |
--textarea-radius | Border radius | var(--rounded-lg) |
--textarea-padding | Inner padding (block inline) | var(--size-2) var(--size-3) |
--textarea-gap | Gap between label and field | Size-dependent |
--textarea-font-size | Font size | var(--text-sm) |
--textarea-placeholder-color | Placeholder text color | Theme-dependent |
--textarea-min-height | Minimum field height | var(--size-24) |
--textarea-max-height | Maximum field height (none = unlimited) | none |
--textarea-resize | CSS resize direction (vertical/horizontal/both/none) | vertical |
--textarea-hover-bg | Field background on hover (flat/ghost variants) | Variant-dependent |
--textarea-hover-border-color | Field border on hover (flat/bordered variants) | Variant-dependent |
--textarea-focus-bg | Field background when focused (flat variant) | Variant-dependent |
--textarea-focus-border-color | Field border when focused (flat variant) | Variant-dependent |
Accessibility
The textarea component follows WCAG 2.1 Level AA standards.
sg-textarea
Tabfocuses the field;Shift+Tabblurs it.- Native textarea keyboard behaviour applies within the field.
aria-labelledbylinks the label;aria-describedbylinks helper and error text.aria-invalidis set whenerroris provided;aria-requiredreflects therequiredattribute.aria-disabledreflects the disabled state.
Best Practices
Do:
- Use
auto-resizefor comment or note fields where content length is unpredictable. - Always provide a
label; don't rely solely onplaceholder. - Set
maxlengthwhen a backend constraint exists — the counter gives live feedback. - Use
errorto surface server-side validation messages after submit.
Don't:
- Set
rowsandauto-resizeat the same time —auto-resizeoverrides the resize handle anyway;rowsstill sets the minimum starting height. - Use
resize="horizontal"on full-width layouts (it breaks layout flow).