Skip to content

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

PreviewCode
RTL

Colors

PreviewCode
RTL

Sizes

PreviewCode
RTL

Labels

Inset (Default)

The label floats inside the field as a compact top label.

PreviewCode
RTL

Outside

The label is placed above the field.

PreviewCode
RTL

Helper & Error Text

PreviewCode
RTL

Character Counter

Set maxlength to enable a live character counter. The counter turns amber near the limit and red at the limit.

PreviewCode
RTL

Auto Resize

Set auto-resize to let the textarea grow vertically with its content. Manual resize is automatically disabled.

PreviewCode
RTL

Resize Control

Control the resize handle with the resize attribute.

PreviewCode
RTL

States

PreviewCode
RTL

API Reference

Attributes

AttributeTypeDefaultDescription
labelstring''Label text
label-placement'inset' | 'outside''inset'Label placement
valuestring''Current value
namestring''Form field name
placeholderstring''Placeholder text
rowsnumber-Visible row count (sets minimum height)
maxlengthnumber-Maximum character count — enables counter when set
helperstring''Helper text below the field
errorstring''Error message (marks field invalid)
disabledbooleanfalseDisable the textarea
readonlybooleanfalseMake the textarea read-only
requiredbooleanfalseMark the field as required
fullwidthbooleanfalseExpand to full width
auto-resizebooleanfalseGrow vertically with content
no-resizebooleanfalseDisable 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

SlotDescription
helperComplex helper content below the field

Events

EventDetailDescription
input{ value: string, originalEvent: Event }Fired on every keystroke
change{ value: string, originalEvent: Event }Fired when value is committed (on blur)

CSS Custom Properties

PropertyDescriptionDefault
--textarea-bgBackground colorVariant-dependent
--textarea-border-colorBorder colorVariant-dependent
--textarea-radiusBorder radiusvar(--rounded-lg)
--textarea-paddingInner padding (block inline)var(--size-2) var(--size-3)
--textarea-gapGap between label and fieldSize-dependent
--textarea-font-sizeFont sizevar(--text-sm)
--textarea-placeholder-colorPlaceholder text colorTheme-dependent
--textarea-min-heightMinimum field heightvar(--size-24)
--textarea-max-heightMaximum field height (none = unlimited)none
--textarea-resizeCSS resize direction (vertical/horizontal/both/none)vertical
--textarea-hover-bgField background on hover (flat/ghost variants)Variant-dependent
--textarea-hover-border-colorField border on hover (flat/bordered variants)Variant-dependent
--textarea-focus-bgField background when focused (flat variant)Variant-dependent
--textarea-focus-border-colorField border when focused (flat variant)Variant-dependent

Accessibility

The textarea component follows WCAG 2.1 Level AA standards.

sg-textarea

Keyboard Navigation
  • Tab focuses the field; Shift+Tab blurs it.
  • Native textarea keyboard behaviour applies within the field.
Screen Readers
  • aria-labelledby links the label; aria-describedby links helper and error text.
  • aria-invalid is set when error is provided; aria-required reflects the required attribute.
  • aria-disabled reflects the disabled state.

Best Practices

Do:

  • Use auto-resize for comment or note fields where content length is unpredictable.
  • Always provide a label; don't rely solely on placeholder.
  • Set maxlength when a backend constraint exists — the counter gives live feedback.
  • Use error to surface server-side validation messages after submit.

Don't:

  • Set rows and auto-resize at the same time — auto-resize overrides the resize handle anyway; rows still sets the minimum starting height.
  • Use resize="horizontal" on full-width layouts (it breaks layout flow).