Dev view

Text Input

A bounded surface for free-text user entry that resolves to a single string value — the canonical primitive for forms. Composes a visible label, an `<input>` element, optional description and error message, and optional leading/trailing decorations. Distinct from SearchInput (which emits a query) and from Combobox (which commits from a constrained list) — TextInput accepts and exposes arbitrary user-typed strings.

Also called Text field Input

When to use

Use

For free-text user input where the resulting value is a single string — names, email addresses, URLs, phone numbers, descriptions under a paragraph in length, prices, quantities. The canonical form primitive — most form fields in most products are TextInput.

Avoid

For free-text search queries that route to a results region — that is `SearchInput`. For multi-value input where each value renders as a tag — that is `TagInput`. For selecting a value from a constrained list — that is `Combobox` or `Select`. For long-form prose (multi-paragraph) — that is `Textarea` (separate component, deferred). For dates — a dedicated DatePicker (deferred). For yes/no choices — that is `Checkbox` or `Switch`.

Versus related

  • search-input

    `SearchInput` emits a free-text query that routes to a separate results region; it has structured submit semantics (Enter, magnifier button, debounced suggestions). `TextInput` accepts and exposes a free-text value; submit behaviour is governed by the parent form. Visually similar; behaviourally distinct — search produces queries, text-input produces values.

  • combobox

    `Combobox` commits a value from a constrained list (the input value is set to the selected option, not the typed text). `TextInput` accepts arbitrary typed text as the value. Use Combobox when the value must come from a finite known set; use TextInput when any string is acceptable.

  • tag-input

    `TagInput` accepts multiple discrete values rendered as inline tokens; `TextInput` accepts a single string. Multi-value input (recipients list, tag set, keyword list) is TagInput-shaped; single-value input is TextInput-shaped.

  • checkbox

    `Checkbox` is a binary form field whose submitted value is the `value` attribute (default `"on"`) only when checked; `TextInput` is a free-text field whose value is the typed string. Both pair with a `<label>` and optional description / error-message slots, but the value type differs — boolean toggle vs string. Use Checkbox for boolean fields; TextInput for typed strings.

  • textarea

    `Textarea` is a multi-line `<textarea>` whose value carries line-breaks; `TextInput` is single-line `<input type="text">` whose value never contains line breaks. The axes diverge: textarea ships `rows`, `resize`, and auto-resize axes; text-input does not. Both share the label-association rules and the Constraint Validation API. Decision test: does the value carry meaningful line-breaks (Textarea) or is it a single-line value (TextInput)?

Text Input is the canonical form primitive — a labeled string-typed entry surface. Many composite components (Search Input, Combobox, Tag Input, conventional Number Input and Password Input sub-types) layer on top of it; the canonical anatomy here documents the universal field contract. The three visual variants (outlined, filled, underlined) represent industry convergence — Material 3, Carbon, and Polaris each pick a default. The reference covers the label-not-placeholder rule, the aria-invalid cross-modal contract, the required-attribute pairing, the autocomplete semantic intent, and the progressive-validation cadence.

Highlight
Fig 1.1 · Text Input · Dev view
Dev

Code anatomy

Slot Code slot Semantic
root root form-field
label label label
input input textbox
description description paragraph
error-message error status-message
leading-icon leading-icon presentational
trailing-icon trailing-icon button-or-presentational
Both

Variants, properties, states

Variants

Structurally different versions of the component.

outlined filled underlined

Properties

The same component, parameterised.

PropertyType
type text | email | tel | url | password | number
size sm | md | lg
invalid boolean
disabled boolean
readonly boolean
required boolean
hasLeadingIcon boolean
hasTrailingIcon boolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
emptyfilledinvalid
Both

State transitions

FromToTrigger
emptyfilledUser types into the input. Required-state error (when `required: true`) clears once the field is non-empty, assuming no other validation rules fail.
filledemptyUser deletes all characters or programmatic clear. For `required: true` fields, transitions back to invalid on blur if validation runs on blur.
filledinvalidValidation fires (typically on blur or on form-submit attempt) and the value violates a constraint — `pattern` mismatch, `type="email"` malformed, `minlength` / `maxlength` boundary, or custom `setCustomValidity()` rule. Error message renders; `aria-invalid="true"` set on the input.
invalidfilledUser edits the value to satisfy the violated rule. Validation re-runs (immediately if validating on-input, on next blur if validating on-blur); `aria-invalid` clears; error message hides.
Dev

Cross-framework expression

FrameworkStructure mechanismVariant mechanism
Web Components A `<ui-text-input>` host wrapping `<label>` + `<input>` + slotted `description` and `error` nodes; `<slot name="leading-icon">` and `<slot name="trailing-icon">` for decorations attributes (`variant="outlined|filled|underlined"`, `size="md"`, `invalid`, `disabled`, `readonly`, `required`); `data-state="empty|filled|invalid"` for CSS
React React Aria `useTextField` (which provides label, input, description, errorMessage props with full ARIA wiring); or a custom `<TextInput>` wrapping the label/input/description/error stack props with class-variance-authority for variant / size; `value` + `onChange(value)` controlled-pattern; `errorMessage` prop drives the error slot and `aria-invalid`
Angular (signals) Angular form-control directive on `<input>` plus a `<ui-form-field>` wrapper for label / description / error projection; reactive forms or template-driven forms for state input<'outlined' | 'filled' | 'underlined'>(); `[size]`, `[required]`, `[disabled]` host bindings; FormControl validators drive invalid state
Vue A `<TextInput>` SFC wrapping `<label>` + `<input>` + named slots for description, error, leading-icon, trailing-icon; `v-model` for value defineProps with literal-union types; `:variant`, `:size`, `:invalid`, `:required` props
Both

Events

  1. valueChange
    Payload
    `{ value: string }`. Fires on every input change. Source of truth for controlled-pattern consumers; fires synchronously with the user's typing, not after blur.
    Web Components
    `valueChange` CustomEvent on the host with `event.detail = { value }`. Native `input` event also bubbles from the inner `<input>`.
    React
    `onChange(value: string)` controlled-pattern callback; React Aria's `onChange` passes the value directly rather than the synthetic event.
    Angular Signals
    `output<string>('valueChange')`; in reactive forms, the FormControl's `valueChanges` observable.
    Vue
    `@update:modelValue` for `v-model`; payload is the new string value.
  2. blur
    Payload
    `{ value: string }`. Fires when focus leaves the input. Canonical trigger for blur-validation patterns (validate on blur, clear on input). Distinct from valueChange because the consumer often wants to debounce side-effects until the user "finishes" the field.
    Web Components
    Native `blur` event on the inner `<input>` bubbles to the host. Composed-true so it crosses the shadow boundary.
    React
    `onBlur(event)` callback; React Aria's `onBlur` passes the input value directly.
    Angular Signals
    Native `(blur)` event binding; for reactive forms the FormControl `statusChanges` observable surfaces the validation transition.
    Vue
    `@blur` event with the native FocusEvent payload.
  3. invalidChange
    Payload
    `{ invalid: boolean, message?: string }`. Fires when the input transitions in or out of the invalid state — either via browser-native constraint validation (`input.checkValidity()` flips) or via custom `setCustomValidity()`. Optional message carries the current validity message for consumers wanting to render it custom.
    Web Components
    `invalidChange` CustomEvent on the host with `event.detail = { invalid, message }`. The native `invalid` event on the inner `<input>` fires only on form-submit-blocked, which is a different cadence.
    React
    Surfaced via the `errorMessage` prop in React Aria's `useTextField`; controlled-pattern consumers recompute it from their own validation logic.
    Angular Signals
    `output<{invalid: boolean, message?: string}>('invalidChange')`; for reactive forms, `statusChanges` + `errors` on the FormControl is the canonical source.
    Vue
    `@invalid-change` event with payload `{ invalid, message }`.
Dev

Form integration

name attribute
The internal `<input>` carries the form `name` attribute. Native browser-native form-data participation: the input's current value submits as a string entry under the configured name when the parent `<form>` submits.
FormData serialization
Single FormData entry per TextInput — `formData.get('email')` returns the current input string. `type="number"` still submits as a string; consumers must coerce. `disabled` excludes the field from FormData; `readonly` includes it. For `type="password"`, the value submits in cleartext — HTTPS at the transport layer is the canonical secrecy boundary, not the input type.
form.reset()
`form.reset()` restores the input to its `defaultValue` (the `value` attribute as authored in HTML, or the initial DOM value). Custom-managed state in framework controlled- patterns must mirror this semantic — resetting a controlled input means dispatching the value back to its initial. Consumers using uncontrolled inputs get this for free.
HTML5 validation
Canonical: HTML5 Constraint Validation API. `required`, `pattern`, `minlength`, `maxlength`, `min`, `max`, `step` are declarative and browser-evaluated. Custom validation via `setCustomValidity('message')`; the field is invalid while the message is non-empty. The `:invalid` and `:user-invalid` CSS pseudo-classes (the latter reflects user-interaction-driven invalidity, not initial-empty- required) drive visual treatment without JS. For server- side errors arriving after submission, mirror them with `setCustomValidity()` so the same UI surfaces both client- and server-validated errors.
Dev

Performance thresholds

  • validationDebouncekeystroke-interval200ms

    For on-input validation (rare; on-blur is canonical), debounce keystrokes by ~200ms so validation does not run on every character. Below the perceptual threshold of feeling laggy; suppresses 80%+ of in-flight stale validations on a 60-80 wpm typer. For canonical on-blur validation, no debounce needed — blur is the natural debounce.

  • maxLengthcharacters10000characters

    Hard upper bound on input length even when no `maxlength` is set — beyond ~10000 characters, browser-native re- layout on every keystroke causes perceptible jank. Canonical `maxlength` for typical fields is much lower (60-255 for most text fields, 320 for emails per RFC 5321 practical limit). Document the threshold as a defence-in- depth ceiling, not as a UX target.

Both

Accessibility

Slot Accessibility hint
root No landmark role. The grouping is implicit — accessibility comes from the explicit label-input association (`<label for>` or `aria-labelledby`) and from `aria-describedby` linking the input to the description and error nodes.
label Real `<label for="<input-id>">`. Programmatic association is non-negotiable — clicking the label focuses the input and screen readers announce the label when focus enters. For visually hidden labels, use a visually-hidden utility (`clip-path` / `position: absolute; width: 1px`) — never `display: none` or `visibility: hidden`, which remove the label from the accessibility tree.
input Native `<input type="text">` (or the type matching the input domain) is canonical. Set `aria-invalid="true"` when the field is in error and `aria-describedby` listing the ids of the description and error message nodes. `autocomplete` should match the field's semantic intent (`email`, `current-password`, `street-address`, etc.) so password managers and platform autofill work; bare `autocomplete="off"` is reserved for fields where autofill is genuinely wrong (one-time codes, captcha-adjacent).
description Reference the description node from the input via `aria-describedby="<description-id>"`. Screen readers announce the description after the label and the input's type — the hint is part of the field's spoken contract. For long descriptions consider truncation rules carefully; SR pronounces the full text regardless of visual clipping.
error-message Reference the error node from the input via `aria-describedby` (in addition to the description, when both are present — multiple ids space-separated). The node should also be `role="alert"` or live-region (`aria-live="polite"`) so dynamic validation surfaces are announced when the message appears asynchronously, not only on focus. Pair with `aria-invalid="true"` on the input — visual styling alone is not the announcement.
leading-icon Decorative — `aria-hidden="true"`. The icon is a visual affordance; the field's accessible name still comes from the `<label>`. Never substitute the icon for the label.
trailing-icon For interactive trailing icons (password reveal, clear), wrap in a real `<button type="button">` with explicit accessible name ("Show password", "Clear value"). For decorative trailing icons (status checkmark on valid input), `aria-hidden="true"`. Document the variant choice — a single visual treatment must not silently switch between interactive and decorative modes.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFocus enters the input. Subsequent Tab moves to the trailing-icon button (if interactive and visible), then out of the field. Disabled inputs skip from focus order; readonly inputs receive focus normally.
Shift+TabReverse focus traversal — out of the field via the label / leading icon (which are not focusable), or back into the previous field.
Type any characterCharacter appends to the input's value. `valueChange` fires synchronously. For type-specific filtering (`type="number"` rejects letters at the platform level), browser handles the rejection; ARIA-invalid does not flip.
Backspace / DeleteRemoves the character at the caret. With a selection active, deletes the selection. `valueChange` fires. For empty-input-with-required, transition to invalid runs on blur not on backspace.
Enter (in a form)Submits the parent `<form>` (browser default). The input itself does not handle Enter — the form's submit handler runs; form validation triggers if not already in invalid state.

Screen-reader announcements

TriggerExpected
Focus enters the inputSR announces the label, the input role ("edit text" for `type="text"`, "email" for `type="email"`), the current value if non-empty, and any `aria-describedby` content (description and / or error message). Required state announces if `required` or `aria-required="true"`.
Validation transitions to invalidPolite live region (`aria-live="polite"` or `role="alert"` on the error node) announces the error message. `aria-invalid="true"` on the input means subsequent re-focus announces "invalid" alongside the label and role.
User types into a previously-invalid fieldValidation re-runs at the validation cadence (typically on blur). On invalid → valid transition, SR re-focus announces normally without the invalid suffix; the error node clears.

axe-core rules to assert

  • aria-input-field-name
  • aria-required-attr
  • aria-valid-attr-value
  • color-contrast
  • label
  • label-content-name-mismatch

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/text-input/a11y-fixture.json

Both

Contracts

Non-negotiable contracts

  1. HTML specHTML label element + WCAG 1.3.1 / 3.3.2

    A `<label for="<input-id>">` element exists in the DOM for every field. Visually-hidden via sr-only is allowed; replacement with `placeholder` is not. Placeholder is reserved for example values, not for field identity.

    Placeholder-as-label vanishes the field's identity once the user starts typing; SR users never hear it (most AT does not announce placeholder as accessible name); users tabbing away and back lose orientation. Research consistently shows placeholder reduces successful form completion.

  2. APGAPG: Form patterns + WCAG 3.3.1 (Error Identification)

    When the field is in error: `aria-invalid="true"` on the input, `aria-describedby` referencing the error message node, and the error node carries `role="alert"` (or `aria-live="polite"` for async announcements). Visual error + ARIA error + invalid attribute ship together.

    Red border without `aria-invalid` is sighted-only; SR users perceive a normal field. The cross-modal contract — every visual cue has an AT cue — is the foundation of accessible forms.

  3. HTML specHTML required attribute + WCAG 3.3.2 (Labels or Instructions)

    Visible required indicators (asterisk, "(required)" suffix) are paired with the `required` attribute on the input. The HTML attribute implies `aria-required="true"` and triggers browser-native constraint validation.

    Visual-only required loses browser validation, leaves SR users unaware the field is mandatory, and shifts all empty-required detection to the server. Worse: SR may pronounce the asterisk as "asterisk" without any required-state announcement.

  4. HTML specHTML autocomplete attribute (WHATWG) + WCAG 1.3.5 (Identify Input Purpose)

    `autocomplete` is set to the field's semantic intent (`email`, `username`, `current-password`, `new-password`, `street-address`, `tel`, `cc-number`, etc.). Sitewide `autocomplete="off"` is reserved for genuinely autofill-hostile fields (one-time codes, captcha, security-question answers).

    Disabling autofill sitewide breaks password managers and platform autofill; user experience degrades for the 80%+ of users who rely on autofill. Worse, WCAG 1.3.5 explicitly requires `autocomplete` for canonical personal-data fields at AA conformance.

  5. Canon

    Validation cadence is progressive: validate on blur (user finishes the field), clear errors on input (user corrects), surface errors at submit only as a fallback for fields the user skipped. Browser-native constraint validation (`type="email"`, `pattern`, `min`/`max`) handles format checks at the right moment.

    Submit-only validation produces forms where users get bounced back to a field they typed minutes earlier; long forms become exhausting; abandonment rates rise. Inline-on-input validation overcorrects in the other direction by yelling at users mid-typing.

Vocabulary drift

Material 3
Text fields (filled / outlined)
Material 3 picks `filled` as default with `outlined` as the alternate; canonical reference documents both plus `underlined` because every variant ships in production somewhere.
Carbon
Text input
Carbon picks `outlined` as default; field-level density and size axes match the canonical anatomy.
Polaris
TextField
Polaris picks `filled` as default with explicit helper-text and error patterns matching the canonical description / error-message slots.
React Aria
TextField
React Aria's `useTextField` hook provides the canonical ARIA wiring (label, input, description, errorMessage) as a single primitive matching this canon.
Headless UI
Input + Field + Label + Description + ErrorMessage
Headless UI does not ship a single `TextInput` primitive; consumers compose `<Field>` plus `<Input>` plus surrounding `<Label>`, `<Description>`, `<ErrorMessage>`. Same composite contract, different granularity.
Dev

Common mistakes

Blocker

#placeholder-as-label

Placeholder used as the only label

Problem

The input has no `<label>`; the field's identity lives in the placeholder, which disappears once the user starts typing. Users who tab away and back lose track of what the field was for; SR users never hear the label at all because placeholder is not part of the accessible name in most assistive-tech behaviour.

Fix

Always render a `<label for="<input-id>">`. Hide it visually with a sr-only utility if the design wants no visible label, but never remove it from the DOM. Reserve `placeholder` for example values ("name@example.com"), and consider omitting placeholder entirely — research consistently shows placeholder text reduces successful form completion.

Blocker

#missing-aria-invalid

Error styling without `aria-invalid`

Problem

The field shows a red border and an error message visually, but the input lacks `aria-invalid="true"`. SR users hear the input as a normal field; the error message may or may not be announced depending on whether `aria-describedby` is wired. The visual signal is not cross-modal.

Fix

Set `aria-invalid="true"` on the input whenever the field is in error. Wire `aria-describedby` to the error message node. Make the error node `role="alert"` or `aria-live="polite"` for asynchronous announcements. Visual-error + ARIA-error + invalid-attribute ship together.

Blocker

#required-without-attribute

Visible required indicator without the `required` attribute

Problem

The label shows a red asterisk; the input lacks the `required` attribute. The user submits the form with the field empty; browser-native validation never fires; server-side validation has to catch what should have been a client-side rejection. Worse: SR users hear the label with the asterisk pronounced as "asterisk" but the field is not announced as required.

Fix

Pair the visual asterisk with the `required` attribute on the input. Browser validation surfaces the empty-required message; `aria-required="true"` (or the `required` attribute itself, which implies aria-required) tells SR the field is mandatory. The visual and the constraint always together.

Major

#wrong-autocomplete

`autocomplete="off"` on every input by default

Problem

The implementation disables autocomplete sitewide as a perceived security or UX measure. Password managers stop working; platform autofill cannot pre-fill addresses, emails, names. User experience degrades for the 80%+ of users who rely on autofill for forms.

Fix

Set `autocomplete` to the field's semantic intent — `email`, `username`, `current-password`, `new-password`, `street-address`, `tel`, `cc-number`, etc. The full token list is in the HTML spec. Reserve `autocomplete="off"` for fields where autofill is genuinely wrong: one-time codes, captcha responses, security-question answers.

Major

#error-only-on-submit

Errors only surface on form submission attempt

Problem

The implementation validates only on form submit. Users type a malformed email, tab away, fill out the rest of the form, hit Submit, and get bounced back to the email field with the error. The friction compounds with each field; long forms become exhausting.

Fix

Validate progressively — show errors on blur (when the user finishes the field) for canonical types, and clear errors on input (as the user corrects). For `type="email"` and similar, browser-native constraint validation handles the format check at the right moment. Document the cadence: validate-on-blur, clear- on-input, surface-on-submit-as-fallback.

Figma↔Code mismatches
  1. 01
    Figma

    TextInput drawn with placeholder text used as the label

    Code

    A field with a real `<label for>` separate from the placeholder

    Consequence

    Designers sometimes treat the placeholder as the label for visual minimalism. The implementation either follows the design (no real label, accessibility breaks; SR users get no field name once the user starts typing) or diverges from the design (adds a label that the design doesn't show). Either way the canonical contract is broken.

    Correct

    Always render a real `<label>` and associate it with the input. If the visual design wants no visible label, hide the label with a visually-hidden utility (clip-path / sr-only) — the label remains in the accessibility tree. The placeholder is a hint about the *value* (an example), not the field's identity. Document the canonical: label always present in the markup; `placeholder` reserved for example values, never for "what is this field".

  2. 02
    Figma

    Required indicator drawn as a decorative asterisk next to the label

    Code

    A field with the `required` attribute on the input

    Consequence

    Designers add a red asterisk in the label as a visual cue. Implementations either copy the asterisk and forget the `required` attribute (form submits with empty values; browser-native validation never fires) or set `required` and forget the asterisk (the constraint exists but the user has no visual signal until validation fires). The visual signal and the constraint diverge.

    Correct

    Both must be present together. The asterisk (or "(required)" text) is decorative and visible; the `required` attribute on the input is the actual constraint and surfaces in browser validation, in `aria-required`, and in form-submission semantics. Document the canonical contract: visual indicator + attribute always paired.

  3. 03
    Figma

    Error message drawn as red text below the input

    Code

    An error message rendered via `aria-describedby` plus `aria-invalid`

    Consequence

    Designers draw red error text. Implementations following the design at the visual layer ship error text that has no programmatic relationship to the input — SR users hear the input's label and type, but never hear the error reason. Visual users see "Email address — Invalid format"; SR users hear "Email address, edit text" with no clue why the field is rejected.

    Correct

    The error message node has an `id`; the input's `aria-describedby` lists that id (alongside any persistent description); the input's `aria-invalid="true"` flags the error state. The error node should be `role="alert"` or `aria-live="polite"` so dynamic validation announces. Document: visual error + ARIA wiring + invalid attribute, always together.

  4. 04
    Figma

    Input variants drawn with different layouts (outlined vs filled vs underlined)

    Code

    A single `<input>` primitive with variant-driven styling

    Consequence

    Designers compose the three variants as separate Figma components with subtly different anatomies (filled has no border but has a tinted background; underlined has only a bottom border; outlined has all four borders). Developers implement them as a single component with a `variant` prop, and the slot positions diverge — the leading icon sits slightly differently across the three.

    Correct

    The canonical anatomy is identical across variants — same slots, same positions, same z-order. Variant differences live in the styling (border, background, padding within the input field) only. Document the variant axis as "visual treatment" not "structural restructuring"; anatomy is stable.

  5. 05
    Figma

    Disabled state drawn with low-contrast text and no other affordance

    Code

    An input with the `disabled` attribute

    Consequence

    Designers grey out the disabled state to ~40% opacity for visual clarity. Without further care, the contrast ratio drops below WCAG 1.4.3 (4.5:1) and SR users hear "edit text, dimmed" but the canonical wording for "this field is currently not editable, here's why" is missing.

    Correct

    Disabled state must still meet 3:1 minimum contrast (WCAG 1.4.11 non-text contrast for the input border). Where possible, prefer `readonly` over `disabled` — readonly keeps the value tabbable and copyable, communicates "value is locked" rather than "field is unavailable", and SR announces "edit text, read only" which is more informative than "disabled". Use `disabled` only when the field is genuinely inactive in the current context.