Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Textarea

A multi-line text field with autogrow and platform-adaptive styling.

A multi-line text field — the multi-line sibling of Input. It shares the input’s chrome: a Material 3 notched outline with a floating label, and an iOS grouped-list surface with an inline label. It grows with its content between a minimum and maximum number of rows.

Installation

npx touchcn add textarea

Usage

<tcn-textarea label="Bio" placeholder="Tell us about yourself" [(value)]="bio" />
import { TcnTextarea } from '@/components/ui/textarea';

<TcnTextarea label="Bio" placeholder="Tell us about yourself" onValueChange={setBio} />
<script setup lang="ts">
import { TcnTextarea } from '@/components/ui/textarea';
</script>

<template>
  <TcnTextarea v-model="bio" label="Bio" placeholder="Tell us about yourself" />
</template>

Autogrow

The field grows with its content between minRows and maxRows, then scrolls. This uses the modern CSS field-sizing: content property, clamped by min-height / max-height derived from the row counts.

field-sizing reached Baseline in 2026 (Chrome 123, Safari 26.2, Firefox 152). No JavaScript is used — so on browsers older than that Baseline the field falls back to the rows attribute (set to minRows), rendering a correctly-sized, internally-scrolling multi-line field. Autogrow is a progressive enhancement on top of that solid baseline; there is no measurement/observer plumbing, keeping the copied component free of engine behavior.

<tcn-textarea label="Notes" [minRows]="3" [maxRows]="8" />
<TcnTextarea label="Notes" minRows={3} maxRows={8} />
<TcnTextarea label="Notes" :min-rows="3" :max-rows="8" />

Props

Prop Type Default Description
label string Floating (MD) / inline (iOS) field label.
value string (model, Angular) '' Two-way bound value.
placeholder string Placeholder text.
minRows number 2 Minimum visible rows; the field never shrinks below this.
maxRows number 6 Maximum rows before the field stops growing and scrolls.
error boolean false Tint the outline / border destructive.
disabled boolean false Disable the field.
onValueChange (value: string) => void Convenience callback with the raw value (React).
Source · Angular
export * from './tcn-textarea';
import { booleanAttribute, Component, input, model, numberAttribute } from '@angular/core';

/**
 * Multi-line text field. Mirrors `tcn-input`'s chrome — a Material 3 notched
 * outline with a floating label (label at rest over the first line, floating to
 * the top border on focus / when filled) and an iOS grouped-list surface with an
 * inline label — sharing the same `.tcn-textarea-*` styling contract in
 * `theme.css`. Like the input it stays theme-dumb: no platform conditionals here.
 *
 * Autogrow: the field grows with its content between `minRows` and `maxRows`.
 * The mechanism is the modern CSS `field-sizing: content` (Baseline as of 2026 —
 * Chrome 123, Safari 26.2, Firefox 152), clamped by `min-height`/`max-height`
 * derived from the row counts (passed to CSS as custom properties). The `rows`
 * attribute is set to `minRows` so browsers older than that Baseline still render
 * a correctly-sized, scrollable multi-line field (the baseline) — no measurement
 * JS is needed, keeping the engine boundary clean.
 */
@Component({
  selector: 'tcn-textarea',
  template: `
    <label
      class="tcn-textarea relative flex flex-col gap-1.5 px-3 py-2.5"
      [class.tcn-textarea-labeled]="!!label()"
      [attr.data-error]="error() || null"
    >
      @if (label()) {
        <span class="tcn-textarea-label">{{ label() }}</span>
      }
      <textarea
        class="tcn-textarea-control w-full bg-transparent text-[var(--color-on-surface)] outline-none"
        [rows]="minRows()"
        [placeholder]="placeholder()"
        [disabled]="disabled()"
        [value]="value()"
        [style.--tcn-textarea-min-rows]="minRows()"
        [style.--tcn-textarea-max-rows]="maxRows()"
        (input)="value.set($any($event.target).value)"
      ></textarea>
      <fieldset class="tcn-textarea-outline" aria-hidden="true">
        @if (label()) {
          <legend class="tcn-textarea-notch">{{ label() }}</legend>
        }
      </fieldset>
    </label>
  `,
  host: { class: 'block' },
})
export class TcnTextarea {
  readonly value = model('');
  readonly label = input('');
  readonly placeholder = input('');
  readonly disabled = input(false, { transform: booleanAttribute });
  readonly error = input(false, { transform: booleanAttribute });
  /** Minimum visible rows (the field never shrinks below this). */
  readonly minRows = input(2, { transform: numberAttribute });
  /** Maximum rows before the field stops growing and scrolls. */
  readonly maxRows = input(6, { transform: numberAttribute });
}
Source · React
export * from './tcn-textarea';
import { forwardRef } from 'react';
import type { ChangeEvent, ComponentPropsWithoutRef, CSSProperties } from 'react';
import { cn } from '@touchcn/core';

export interface TcnTextareaProps extends Omit<ComponentPropsWithoutRef<'textarea'>, 'rows'> {
  /** Floating (MD) / inline (iOS) field label. */
  label?: string;
  /** Error state — tints the outline / border destructive. */
  error?: boolean;
  /** Minimum visible rows (the field never shrinks below this). */
  minRows?: number;
  /** Maximum rows before the field stops growing and scrolls. */
  maxRows?: number;
  /** Wrapper class applied to the field host. */
  wrapperClassName?: string;
  /** Convenience callback with the raw string value. */
  onValueChange?(value: string): void;
}

/**
 * Multi-line text field. Mirrors `TcnInput`'s chrome — a Material 3 notched
 * outline with a floating label and an iOS grouped-list surface with an inline
 * label — sharing the same `.tcn-textarea-*` styling contract in `theme.css`, so
 * markup/classes stay identical to the Angular component.
 *
 * Autogrow: the field grows with its content between `minRows` and `maxRows` via
 * the modern CSS `field-sizing: content` (Baseline as of 2026 — Chrome 123,
 * Safari 26.2, Firefox 152), clamped by `min-height`/`max-height` derived from
 * the row counts. The `rows` attribute is set to `minRows` so browsers older than
 * that Baseline still render a correctly-sized, scrollable field — no measurement
 * JS, keeping the engine boundary clean.
 */
export const TcnTextarea = forwardRef<HTMLTextAreaElement, TcnTextareaProps>(function TcnTextarea(
  { label, error, minRows = 2, maxRows = 6, className, wrapperClassName, onValueChange, onChange, style, ...props },
  ref,
) {
  const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
    onChange?.(event);
    onValueChange?.(event.target.value);
  };

  const rowStyle = {
    ...style,
    '--tcn-textarea-min-rows': minRows,
    '--tcn-textarea-max-rows': maxRows,
  } as CSSProperties;

  return (
    <div className={cn('block', wrapperClassName)}>
      <label
        className={cn('tcn-textarea relative flex flex-col gap-1.5 px-3 py-2.5', label && 'tcn-textarea-labeled')}
        data-error={error || undefined}
      >
        {label && <span className="tcn-textarea-label">{label}</span>}
        <textarea
          ref={ref}
          className={cn('tcn-textarea-control w-full bg-transparent text-[var(--color-on-surface)] outline-none', className)}
          rows={minRows}
          style={rowStyle}
          onChange={handleChange}
          {...props}
        />
        <fieldset className="tcn-textarea-outline" aria-hidden="true">
          {label && <legend className="tcn-textarea-notch">{label}</legend>}
        </fieldset>
      </label>
    </div>
  );
});
Source · Vue
<script setup lang="ts">
import { computed } from 'vue';
import type { CSSProperties } from 'vue';
import { cn } from '@touchcn/core';

/**
 * Multi-line text field. Mirrors `TcnInput`'s chrome — a Material 3 notched
 * outline with a floating label and an iOS grouped-list surface with an inline
 * label — sharing the same `.tcn-textarea-*` styling contract in `theme.css`, so
 * markup/classes stay identical across frameworks.
 *
 * Autogrow: the field grows with its content between `minRows` and `maxRows` via
 * the modern CSS `field-sizing: content` (Baseline as of 2026 — Chrome 123,
 * Safari 26.2, Firefox 152), clamped by `min-height`/`max-height` derived from
 * the row counts. The `rows` attribute is set to `minRows` so browsers older
 * than that Baseline still render a correctly-sized, scrollable field — no
 * measurement JS, keeping the engine boundary clean. Two-way bound with
 * `v-model`; extra attributes (`placeholder`, `disabled`, …) fall through to the
 * control.
 */
defineOptions({ inheritAttrs: false });

const props = withDefaults(
  defineProps<{
    /** Floating (MD) / inline (iOS) field label. */
    label?: string;
    /** Error state — tints the outline / border destructive. */
    error?: boolean;
    /** Minimum visible rows (the field never shrinks below this). */
    minRows?: number;
    /** Maximum rows before the field stops growing and scrolls. */
    maxRows?: number;
    /** Wrapper class applied to the field host. */
    wrapperClassName?: string;
  }>(),
  { minRows: 2, maxRows: 6 },
);

const model = defineModel<string>();

const rowStyle = computed<CSSProperties>(
  () =>
    ({
      '--tcn-textarea-min-rows': props.minRows,
      '--tcn-textarea-max-rows': props.maxRows,
    }) as CSSProperties,
);
</script>

<template>
  <div :class="cn('block', wrapperClassName)">
    <label
      :class="cn('tcn-textarea relative flex flex-col gap-1.5 px-3 py-2.5', label && 'tcn-textarea-labeled')"
      :data-error="error || undefined"
    >
      <span v-if="label" class="tcn-textarea-label">{{ label }}</span>
      <textarea
        v-model="model"
        class="tcn-textarea-control w-full bg-transparent text-[var(--color-on-surface)] outline-none"
        :rows="minRows"
        :style="rowStyle"
        v-bind="$attrs"
      />
      <fieldset class="tcn-textarea-outline" aria-hidden="true">
        <legend v-if="label" class="tcn-textarea-notch">{{ label }}</legend>
      </fieldset>
    </label>
  </div>
</template>
export { default as TcnTextarea } from './TcnTextarea.vue';

Last updated on July 24, 2026

Was this page helpful?