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
- 🎨 6 Variants — solid, flat, bordered, outline, ghost, frost
- 🏷️ 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 { computed, defineComponent, effect, html, onMount, ref } from '@vielzeug/craftit';
import { attr } from '@vielzeug/craftit/directives';
import type { VisualVariant } from '../../types';
import type { TextFieldProps } from '../shared/base-props';
import { disabledLoadingMixin, forcedColorsFocusMixin, formFieldMixins, sizeVariantMixin } from '../../styles';
import { useTextField } from '../shared/composables';
import { TEXTAREA_SIZE_PRESET } from '../shared/design-presets';
import { setupFieldEvents, syncCounter, syncMergedAssistive } from '../shared/dom-sync';
import { parsePositiveNumber } from '../shared/utils';
import componentStyles from './textarea.css?inline';
/** Textarea component properties */
export type BitTextareaEvents = {
change: { originalEvent: Event; value: string };
input: { originalEvent: Event; value: string };
};
export type BitTextareaProps = TextFieldProps<Exclude<VisualVariant, 'glass' | 'frost' | 'text'>> & {
/** Allow auto-grow with content */
'auto-resize'?: boolean;
/** Maximum character count; shows a counter when set */
maxlength?: number;
/** Disable manual resize handle */
'no-resize'?: boolean;
/** 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 bit-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
* @attr {string} variant - Visual variant
* @attr {string} size - Component size
* @attr {string} rounded - Border radius
*
* @fires input - Fired on every keystroke with current value
* @fires change - Fired on blur with changed value
*
* @slot helper - Complex helper content
*/
export const TEXTAREA_TAG = defineComponent<BitTextareaProps, BitTextareaEvents>({
formAssociated: true,
props: {
'auto-resize': { default: false },
color: { default: undefined },
disabled: { default: false },
error: { default: '', omit: true },
fullwidth: { default: false },
helper: { default: '' },
label: { default: '' },
'label-placement': { default: 'inset' },
maxlength: { default: undefined },
name: { default: '' },
'no-resize': { default: false },
placeholder: { default: '' },
readonly: { default: false },
required: { default: false },
resize: { default: undefined },
rounded: { default: undefined },
rows: { default: undefined },
size: { default: undefined },
value: { default: '' },
variant: { default: undefined },
},
setup({ emit, props }) {
const tf = useTextField(props, 'textarea');
const {
fieldId: textareaId,
helperId,
labelInsetId,
labelInsetRef,
labelOutsideId,
labelOutsideRef,
valueSignal,
} = tf;
const maxLen = computed<number | undefined>(() => props.maxlength.value);
const textareaRef = ref<HTMLTextAreaElement>();
const helperRef = ref<HTMLDivElement>();
const counterRef = ref<HTMLSpanElement>();
onMount(() => {
const ta = textareaRef.value;
if (!ta) return;
const autoGrow = () => {
if (!props['auto-resize'].value || !ta) return;
ta.style.height = 'auto';
ta.style.height = `${ta.scrollHeight}px`;
};
tf.mountLabelSync();
effect(() => {
const rows = parsePositiveNumber(props.rows.value);
if (rows != null) ta.rows = rows;
else ta.removeAttribute('rows');
const max = parsePositiveNumber(maxLen.value);
if (max != null) ta.maxLength = max;
else ta.removeAttribute('maxlength');
ta.style.resize =
props['auto-resize'].value || props['no-resize'].value ? 'none' : props.resize.value || 'vertical';
});
syncMergedAssistive({
error: () => props.error.value,
helper: () => props.helper.value,
ref: helperRef,
});
syncCounter({
count: computed(() => valueSignal.value.length),
format: 'merged',
maxLength: maxLen,
ref: counterRef,
});
// TODO: migrate aria() on inner elements to a future useA11yField() composable
import('@vielzeug/craftit').then(({ aria }) => {
aria(ta, {
describedby: () => (props.error.value ? tf.errorId : helperId),
invalid: () => !!props.error.value,
labelledby: () => (props['label-placement'].value === 'outside' ? labelOutsideId : labelInsetId),
});
});
setupFieldEvents(ta, {
onBlur: () => tf.triggerValidation('blur'),
onChange: (e, value) => {
emit('change', { originalEvent: e, value });
tf.triggerValidation('change');
},
onInput: (e, value) => {
autoGrow();
emit('input', { originalEvent: e, value });
},
});
if (props['auto-resize'].value) requestAnimationFrame(autoGrow);
});
return html`
<div class="textarea-wrapper">
<label class="label-outside" for="${textareaId}" id="${labelOutsideId}" ref=${labelOutsideRef} hidden></label>
<div class="field">
<label class="label-inset" for="${textareaId}" id="${labelInsetId}" ref=${labelInsetRef} hidden></label>
<textarea
ref=${textareaRef}
id="${textareaId}"
${attr({
disabled: props.disabled,
name: props.name,
placeholder: props.placeholder,
readOnly: props.readonly,
required: props.required,
value: valueSignal,
})}
aria-describedby="${helperId}"></textarea>
</div>
<span class="counter" aria-live="polite" ref=${counterRef} hidden></span>
<div id="${helperId}" class="helper-text" aria-live="polite" ref=${helperRef} hidden></div>
</div>
`;
},
shadow: { delegatesFocus: true },
styles: [
...formFieldMixins,
sizeVariantMixin(TEXTAREA_SIZE_PRESET),
disabledLoadingMixin(),
forcedColorsFocusMixin('textarea'),
componentStyles,
],
tag: 'bit-textarea',
});Basic Usage
html
<bit-textarea label="Message" placeholder="Write something..."></bit-textarea>
<script type="module">
import '@vielzeug/buildit/textarea';
</script>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' | 'frost' | '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 | var(--color-contrast-100) |
--textarea-border-color | Border color | var(--color-contrast-300) |
--textarea-radius | Border radius | var(--rounded-lg) |
--textarea-padding | Padding | var(--size-2) var(--size-3) |
--textarea-font-size | Font size | var(--text-sm) |
--textarea-placeholder-color | Placeholder color | var(--color-contrast-500) |
--textarea-min-height | Minimum height | var(--size-24) |
--textarea-max-height | Maximum height | none |
Accessibility
The textarea component follows WCAG 2.1 Level AA standards.
bit-textarea
✅ Keyboard Navigation
Tabfocuses the field;Shift+Tabblurs it.- Native textarea keyboard behaviour applies within the field.
✅ Screen Readers
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).