Skip to content

Inputs

Text Input

Beta

A single-line text field with a label, optional icons, clear button, character counter and full validation states. Available on web (React) and native (React Native).

Sizes

Small

Dense UI, compact forms.

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

Medium

The default size for most layouts.

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

Large

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

Extra large

<TextInput label="Extra large" size="xl" placeholder="Placeholder" />

XX large

<TextInput label="XX large" size="xxl" placeholder="Placeholder" />

Label placement

Adjacent (default)

Label sits above the field.

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

Inline (floating)

Label floats inside the field. Unsupported at size sm (illegible 8px) — see Accessibility.

<TextInput label="Inline (floating) label" labelType="inline" />

Rounded

Rounded, with clear

<TextInput rounded label="Search" leadingIcon={MaterialSearch} isClearable defaultValue="Plumber" />

Icons and clear button

Leading icon + clear

The clear button appears once the field has a value.

<TextInput label="Search" leadingIcon={MaterialSearch} isClearable defaultValue="Plumber" placeholder="Search…" />

Character counter

With maxLength

5/20 form. Purely informational — maxLength enforces the limit regardless.

7/20

<TextInput label="Bio" showCount maxLength={20} defaultValue="Plumber" />

Validation states

Invalid

Enter a valid email.
<TextInput label="Email" defaultValue="not-an-email" isInvalid errorMessage="Enter a valid email." />

Valid

Border + label ink only — Figma's valid state has no check icon.

<TextInput label="Email" defaultValue="hello@checkatrade.com" isValid />

Loading

Stays fully editable. The spinner replaces the clear button (isClearable is set here, but the ✕ doesn't show).

<TextInput label="Search" defaultValue="Checking…" isClearable isLoading />

Disabled

<TextInput label="Email" defaultValue="locked@checkatrade.com" isDisabled />

When to use

Use TextInput for any single-line free-text entry in a form — name, email, search, a short bio. It covers labels (adjacent or floating), leading/trailing icons, a clear button, a character counter, and the full validation state set (invalid / valid / disabled), so most single-line form fields need no custom styling on top.

When not to use

  • Multi-line text: a TextInput is single-line only; a text area is a separate, not-yet-built component (Input Text Area, coming soon).
  • A fixed set of choices: use a select, radio group, or checkbox group instead of free text.
  • A value with its own dedicated picker (date, file): use the dedicated component once available — don’t reimplement picker behaviour inside a text field.

Props

TextInput shares one label / description / helper / validation contract (FieldProps) across both packages. The control model and change handler differ by platform — see the notes below the table, and “Platform status” for the full breakdown.

Prop Type Default Description
label * string - Visible label and the accessible name of the control.
description ReactNode - Rich secondary content under the label. Not rendered when labelType="inline" (logs a console.warn if passed together) — the description slot only exists in the adjacent label.
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 (omitted, not set false, when isRequired is false — see Accessibility). Renders a visible "*" marker in both adjacent and inline modes (Design call, Oli, 2026-09-18) — the inline one is aria-hidden, so aria-required on the control does the announcing.
isDisabled boolean false Disables interaction. Wins over every other state.
isInvalid boolean false Error border + aria-invalid; reveals errorMessage in place of helperText. Loses to isDisabled.
isValid boolean false Success border + label ink only — Figma's valid state has no check icon (confirmed with Oli). Loses to isInvalid (both lose to isDisabled).
value string - Web: controlled value — omit and use defaultValue for uncontrolled usage. Native: value is 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<HTMLInputElement>) => 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' | 'xl' | 'xxl' 'md' sm is blocked with labelType="inline" (illegible 8px floated label) — type-level union + a non-dev-gated console.warn + md fallback on both platforms.
labelType 'adjacent' | 'inline' 'adjacent' inline floats the label inside the field (CSS-only on web, Reanimated on native). Excludes size="sm".
rounded boolean false Fully-rounded corners.
isLoading boolean false Trailing spinner; field stays fully editable on both platforms. The spinner replaces both the clear button and trailingIcon while loading — identical on web and native.
leadingIcon / trailingIcon ComponentType<SVGProps<SVGSVGElement>> (web) / SVG asset import (native) - Trailing coexists with the clear button (unless isLoading, which replaces both). Same web-vs-native shape as the DS Icon component.
isClearable / onClear boolean / () => void - ✕ shown when there is a value and the field is not isLoading. Clearing an uncontrolled web field goes through the native input value setter + a dispatched input event, so React/RHF observe it.
maxLength / showCount number / boolean - Counter ("5/20" with maxLength, else "5") shows only when showCount.
type 'text' | 'email' | 'password' | 'tel' | 'url' | 'search' 'text' Web only. number is deliberately excluded — use tel for numeric entry.
ref Ref<HTMLInputElement> (web) / Ref<RN TextInput> (native) - Forwarded to the underlying input (focus(), blur(), select()/clear(), …).
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, -leading-icon, -trailing-icon, -clear, -spinner, -count, -helper-icon, -label-row.

size is deliberately omitted from web’s InputHTMLAttributes — it collides with the native HTML size attribute (a character-width number), so the DS reuses the prop name for its own size scale instead.

Import

Web

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

The CSS import is required. Padding (px-input-*/py-input-*) is hand-written @utility — Tailwind has no auto-generation path for an arbitrary padding sub-scale. Radius (rounded-input-*) and the type-scale (text-input-*) are Tailwind-auto-generated from field.css’s @theme inline block, no @utility needed for those. Either way, without the import none of the underlying theme variables exist, so the field renders with no padding, radius, or type-scale at all.

Native

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

Basic usage

Controlled (both platforms):

// Web
const [email, setEmail] = useState('');
<TextInput label="Email" value={email} onChange={(e) => setEmail(e.target.value)} />;

// Native
const [email, setEmail] = useState('');
<TextInput label="Email" value={email} onChangeText={setEmail} />;

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

const { register } = useForm();
<TextInput label="Email" {...register('email')} />;

Platform status

Platform / AreaStatus
Design (Figma) Beta
Web (React) Beta
Native (React Native) Beta
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) — react-hook-form’s register() is uncontrolled on web but <Controller>-only on React Native, so this isn’t a gap to close, it’s the two platforms’ own form-library idioms.

Figma’s intended sm height is 40px (confirmed with Oli) — but neither platform actually renders that. Web measures 43px: Chromium floors a text <input>’s content-box height at the font’s natural line box, so the component’s line-height: 1 value text renders a few px taller than the padding + line-height tokens alone predict, at every size (sm 43 / md 53 / lg 49 / xl 61 / xxl 71.5px), not only sm. Native hardcodes a per-size minHeight (44 / 48 / 56 / 64 / 72) that was never checked against Figma beyond sm, and — because native gives the input an explicit pixel height rather than relying on line-height — avoids web’s Chromium quirk without matching web’s numbers either (measuring 44 / 50 / 56 / 64 / 72). The two platforms differ from each other at every size, not just from Figma’s 40px intent.

Accessibility

Web

  • aria-required / aria-invalid are set from isRequired / isInvalid (omitted, not set false, when not applicable — a clean DOM rather than a noisy “false” announcement).
  • aria-describedby points at whichever of the description / helper / error slots actually renders, generated from the field’s id (via useId() if none is supplied).
  • Keyboard-only focus ring on the field row (lg-at-md-offset, matching Button/Link) is self-contained in @checkatrade/components-web/css. The clear button’s own ring comes from a separate import, @checkatrade/tokens/css/base (the same global button:focus-visible rule Button relies on) — importing only the components CSS still leaves the row ring intact but drops the clear button’s.
  • labelType="inline" renders the required * marker inside the floating label (Design call, Oli, 2026-09-18), as an aria-hidden <span> so the accessible name stays exactly the label and aria-required on the control does the announcing. Native matches. The description slot is still omitted in inline mode — there is no adjacent block to render it in.
  • The character counter (Text tone="secondary") renders at #999998 on white — 2.85:1 contrast, below the 4.5:1 WCAG AA minimum for this text size. This is a shared-token issue (the same token is used by Card, Modal, and Text’s own stories, and native’s counter uses the identical tone) — not fixed here, since patching just this component’s tone would mask the token bug everywhere else it appears. The counter is redundant information; maxLength enforces the limit regardless of whether the count is legible.

React Native

  • accessibilityState/aria-invalid/aria-required mirror the web attributes; aria-invalid/aria-required are only passed when true (RN-web forces the DOM attribute merely by its presence, even ={false}).
  • Focus ring is md-at-0 (not web’s lg-at-md-offset) — a pre-existing native deviation from Button/Link, not a bug introduced here.
  • Hover state is mirrored from web via pointer events on react-native-web; it’s a no-op on iOS/Android.
  • The native input never sets lineHeight (RN only re-centres text with it in the <Text> layout path, not TextInput) — the size token’s line height is applied as an explicit height instead, which is also what avoids clipping the descender on characters like “y”.

Both platforms

  • Tone is never the only signal: invalid/valid states pair a border colour change with visible copy (errorMessage) or label ink, never colour alone.
  • isLoading never disables the field on either platform — it stays fully editable while an async check runs. The spinner replaces both the clear button and any trailingIcon on both platforms, identically (an earlier version of the web component omitted this on the clear button; that was an unintentional gap, now fixed to match native). Design may prefer the clear button to stay available during loading — that hasn’t been decided either way.
  • Control model: web supports both controlled (value + onChange) and uncontrolled (defaultValue, no onChange required) usage; native is always controlled (value + onChangeText, both required). Neither the accessible name nor any ARIA state depends on which form is used — this only affects how a consumer wires the value, not what assistive technology announces. See “Platform status” above for why the two differ.
  • Target size: sm’s field height is intended (Figma) to be 40px, but web actually renders 43px and native 44px — see “Platform status” above for why neither platform hits 40 exactly. All three numbers meet WCAG 2.2 SC 2.5.8 (Minimum, AA, 24×24); of the three, only native’s 44px meets SC 2.5.5 (Enhanced, AAA, 44×44), and only by landing exactly on the threshold, coincidentally — not because that value was chosen for AAA compliance. Web’s actual 43px is closer to the AAA target than Figma’s intended 40px would have been, so this particular discrepancy is harmless for touch-target size specifically.

No releases yet.