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';