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
<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
TextInputinstead.TextAreadocuments (and warns on)rows < 2rather than supporting it. A single-line field is whatTextInputis 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 / Area | Status |
|---|---|
| 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:
| Configuration | Measured 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-invalidare set fromisRequired/isInvalid(omitted, not setfalse, when not applicable).aria-describedbypoints at whichever of the description / helper / error slots actually renders.- The resize grabber is
aria-hiddenand 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 thedescriptionslot entirely, same asTextInput.
React Native
accessibilityState/aria-invalid/aria-requiredmirror 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). isLoadingnever 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 affectingTextInputidentically:colorInputLabelValidandcolorInputLabelDisabledare the same value (#4d4d4c), so a valid field’s label is visually indistinguishable from a disabled one’s.
No releases yet.