Skip to content

Inputs

Text Area

Stable

A multi-line text field with a shrinking inline label, drag-to-resize and a character counter. Available on web (React) and native (React Native).

Sizes

Small

Dense UI, compact forms.

<TextArea label="Small" size="sm" placeholder="Placeholder" />

Medium

The default size for most layouts.

<TextArea label="Medium" size="md" placeholder="Placeholder" />

Large

<TextArea label="Large" size="lg" placeholder="Placeholder" />

Only three sizes: no xl/xxl, and no rounded variant. Figma defines neither for TextArea.

Label placement

Adjacent (default)

Label sits above the field, full size, at the top.

<TextArea label="Adjacent label" labelType="adjacent" placeholder="Placeholder" />

Inline

Label shrinks in place inside the field once focused or filled, a flow reflow, not TextInput's floating transform.

<TextArea label="Inline label" labelType="inline" placeholder="Placeholder" />

sm + inline

Supported here as a documented stopgap, even though TextInput blocks the identical combination (same 8px shrunk-label token). See "Code" tab.

<TextArea label="sm inline" size="sm" labelType="inline" />

Rows

Default (3 rows)

<TextArea label="Bio" placeholder="Tell us about yourself…" />

Custom rows

rows is the idiomatic sizing mechanism: it survives a font change, browser zoom and the label-shrink reflow, unlike a fixed height.

<TextArea label="Details" rows={6} placeholder="More room to write…" />

Supported range is rows >= 2. At rows={1} the resize grabber overlaps the trailing icon/spinner cluster. Passing a lower value logs a dev-only warning rather than being clamped. Use TextInput for a genuinely single-line field.

Resize

Drag to resize

Vertical only. Figma draws a custom grabber (bottom-right) in place of the browser's native resize affordance.

<TextArea label="Notes" defaultValue="Drag the corner…" />

Not resizable

<TextArea label="Fixed size" resizable={false} />

Icons and character counter

Leading icon

<TextArea label="Search notes" leadingIcon={MaterialSearch} placeholder="Search…" />

With maxLength

5/200 form. Purely informational: maxLength enforces the limit regardless.

34/200

<TextArea label="Bio" showCount maxLength={200} defaultValue="Plumber with 10 years' experience." />

Validation states

Invalid

Enter at least 10 characters.
<TextArea label="Feedback" defaultValue="x" isInvalid errorMessage="Enter at least 10 characters." />

Valid

Border + label ink only: Figma's valid state has no check icon, matching TextInput.

<TextArea label="Feedback" defaultValue="Great service, arrived on time." isValid />

Loading

Stays fully editable. The spinner replaces trailingIcon.

<TextArea label="Feedback" defaultValue="Checking…" isLoading />

Disabled

<TextArea label="Feedback" defaultValue="Locked" isDisabled />

When to use

Use TextArea for multi-line free-text entry: bio, feedback, notes, anything longer than a single line. It reuses the same shell as TextInput (label, description, helper/error, character counter) with a resizable multi-line body on top.

When not to use

  • Single-line text: use TextInput instead. TextArea documents (and warns on) rows < 2 rather than supporting it. A single-line field is what TextInput is for.
  • A fixed set of choices: use a select, radio group, or checkbox group instead of free text.

Both platforms

TextArea ships to @checkatrade/components-web and @checkatrade/components-native from the same Figma frame, at parity. See “Code” for the one real control-model difference between them.

Native is controlled-only: value and onChangeText are both required, with no defaultValue/uncontrolled path. This mirrors TextInput’s identical asymmetry and for the same reason: react-hook-form’s register() is uncontrolled on web but <Controller>-only on React Native, so an uncontrolled path on native would buy nothing. It isn’t a gap to close, it’s the two platforms’ own form-library idioms.

Props

TextArea shares the same FieldProps contract (label, description, helperText, errorMessage, isRequired, isDisabled, isInvalid, isValid) as TextInput. The control model and change handler differ by platform. See “Usage” above and “Platform status” below.

Prop Type Default Description
label * string - Visible label and the accessible name of the control.
description ReactNode - Web only. Not rendered when labelType="inline" (logs a console.warn if passed together). Native has no equivalent warning for this combination.
helperText string - Helper text below the field. Replaced by errorMessage when isInvalid.
errorMessage string - Error message below the field, shown when isInvalid.
isRequired boolean false Wires aria-required/accessibilityState regardless of labelType. Visible "*" marker in adjacent mode only. Inline mode has no adjacent block to render it in.
isDisabled boolean false Disables interaction and hides the resize grabber. Wins over every other state.
isInvalid boolean false Error border. Reveals errorMessage in place of helperText. Loses to isDisabled.
isValid boolean false Success border + adjacent-label ink only: no check icon. Loses to isInvalid (both lose to isDisabled). The inline label does NOT recolour on isValid (colorInputLabelValid and colorInputLabelDisabled are the same token value).
value string - Web: controlled value. Omit and use defaultValue for uncontrolled usage. Native: required and always controlled (no defaultValue, no uncontrolled path).
defaultValue string - Web only. Initial value for uncontrolled usage. Ignored when value is provided.
onChange (event: ChangeEvent<HTMLTextAreaElement>) => void - Web only. The raw inherited DOM change handler, not re-declared. This is what makes react-hook-form's uncontrolled register() spread work untouched.
onChangeText (value: string) => void - Native only. Required: native is always controlled.
size 'sm' | 'md' | 'lg' 'md' No xl/xxl and no rounded variant. Figma defines neither for TextArea.
labelType 'adjacent' | 'inline' 'adjacent' inline shrinks the label in place inside the field (a flow reflow, not a floating transform). Unlike TextInput, sm + inline IS supported here: a documented legibility stopgap, not blocked at the type level.
rows number 3 Visible text rows. Supported range is rows >= 2. Below that, a resize grabber/adornment overlap is logged as a dev-only warning rather than clamped.
resizable boolean true Show the drag-to-resize grabber. Vertical only. Hidden entirely when isDisabled.
isLoading boolean false Trailing spinner, replacing trailingIcon. Field stays fully editable.
leadingIcon / trailingIcon ComponentType<SVGProps<SVGSVGElement>> (web) / SVG asset import (native) - Top-aligned (items-start), unlike TextInput's centred row. No clear button on TextArea, so trailingIcon never competes with one.
maxLength / showCount number / boolean - Counter ("5/200" with maxLength, else "5") shows only when showCount.
ref Ref<HTMLTextAreaElement> (web) / Ref<RN TextInput> (native) - Forwarded to the underlying input.
id string - Web only. Generated via useId() if omitted. Also derives the -description/-helper/-error ids wired into aria-describedby.
accessibilityLabel string label Native only.
testID string - Native only. Sub-ids: -row, -input, -content, -leading-icon, -trailing-icon, -spinner, -count, -helper-icon, -label-row, -resize.

No rounded and no isClearable/onClear. Neither exists on this component on either platform (Figma defines no rounded variant for TextArea, and it has no clear button).

Import

Web

import { TextArea } from '@checkatrade/components-web';
import '@checkatrade/components-web/css';

The CSS import is required. It carries TextArea’s own type-scale and the resize-grabber inset alongside TextInput’s.

Native

import { TextArea } from '@checkatrade/components-native';

Basic usage

Controlled (both platforms):

// Web
const [bio, setBio] = useState('');
<TextArea label="Bio" value={bio} onChange={(e) => setBio(e.target.value)} rows={4} />;

// Native
const [bio, setBio] = useState('');
<TextArea label="Bio" value={bio} onChangeText={setBio} rows={4} />;

Uncontrolled (web only), the shape react-hook-form’s register() depends on:

const { register } = useForm();
<TextArea label="Bio" {...register('bio')} />;

Platform status

Platform / AreaStatus
Design (Figma) Stable
Web (React) Stable
Native (React Native) Stable
iOS (Swift) Planned
Android (Kotlin) Planned
Accessibility audit Planned

Web supports both controlled (value + onChange) and uncontrolled (defaultValue, no onChange required) usage. Native is controlled-only (value + onChangeText, both required). See “Usage” for why.

rows and Figma’s drawn height

rows defaults to 3. Figma’s frame is a fixed 120px, drawn at rest. Measured directly in a browser (2026-09-10), rows={3} renders:

ConfigurationMeasured height
md, adjacent label 106px (14px shorter than Figma’s 120px)
sm, adjacent label 89px
lg, adjacent label 102px
md, inline label, resting (empty) 130px (10px taller than Figma’s 120px)
md, inline label, shrunk (has a value) 116px

The inline configuration’s height genuinely changes between rest and filled/focused: the label-shrink reflow is real, not just a label resizing in place. Open question for Oli: whether the rows-derived height above or Figma’s drawn 120px is the intended one. Not yet resolved.

Known limitations: native resize gesture unverified

The native drag-to-resize gesture has not been verified on a device or simulator. This is a gap in verification, not a claim that it works or doesn’t. The RN-web preview cannot substitute for this: reproducing a real touch/pan gesture is exactly what that surface fails to do. Still unverified: the gesture’s actual feel (native commits height on gesture end, not per frame, a deliberate divergence from web, since height is a layout property and animating one forces a Yoga pass every frame); touch priority between the resize grabber and the row’s own pressable area; the resize glyph’s appearance at 16px on a real screen; and whether Android’s numberOfLines (derived from rows) is a height hint or a hard line cap on a multiline field. If it caps, rows would silently limit how much text a user can type, a functional bug rather than a layout one.

Accessibility

Web

  • aria-required / aria-invalid are set from isRequired / isInvalid (omitted, not set false, when not applicable).
  • aria-describedby points at whichever of the description / helper / error slots actually renders.
  • The resize grabber is aria-hidden and not focusable: a keyboard user cannot drag it, and the field is already resizable via the browser’s own zoom.
  • labelType="inline" omits the visible required * marker and the description slot entirely, same as TextInput.

React Native

  • accessibilityState/aria-invalid/aria-required mirror the web attributes.
  • The resize grabber is hidden from assistive technology (accessibilityElementsHidden + importantForAccessibility).
  • Hover state is mirrored from web via pointer events on react-native-web, a no-op on iOS/Android.

Both platforms

  • Tone is never the only signal: the invalid state pairs a border colour change with visible copy (errorMessage).
  • isLoading never disables the field: it stays fully editable while an async check runs.
  • Control model: web supports both controlled and uncontrolled usage. Native is always controlled. Neither affects what assistive technology announces. See “Usage” above.
  • Shared, upstream token issues: not TextArea-specific, and affecting TextInput identically: colorInputLabelValid and colorInputLabelDisabled are the same value (#4d4d4c), so a valid field’s label is visually indistinguishable from a disabled one’s.

No releases yet.