BetaForms

TextField

A text field, with the label, the hint and the error that make it usable.

Overview

A text field, with the label, the hint and the error that make it usable.

First example

<TextField>
<TextField.Label>Courriel</TextField.Label>
<TextField.Field
value={email}
onChangeText={setEmail}
placeholder="nom@exemple.fr"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
<TextField.Description>On ne le partage jamais.</TextField.Description>
</TextField>

Anatomy

<TextField>
  <TextField.Label />
  <TextField.Field />
  <TextField.Description />
  <TextField.Error />
</TextField>
  • TextField — the root, and it is the column, not the field. A View that resolves the recipe once and publishes the resolved styles to its slots. It owns the focus state, because the recipe resolves on it (R5).

  • TextField.Field — the TextInput. Everything TextInput accepts is written here: value, onChangeText, keyboardType, secureTextEntry, autoComplete.

  • TextField.Label — what the field is for. It carries the id the field points at.

  • TextField.Description — the hint under the field: the format expected, what the value is used for.

  • TextField.Error — what is wrong with the value, in danger.

The root is the column so that the label, the hint and the error are slots of one component rather than three components a form has to keep in step. That is also why TextInputProps are on TextField.Field and not on the root: the field is the node that has them.

No auto-wrap (R3), unlike Button, Card, Chip and Alert: a string child of an input is not a label, a value or a placeholder in any way the component could guess.

No slot carries a margin (R4). What separates the three lines is the root's gap, so JSX order is screen order — a label under the field is a matter of where you wrote it.

Usage

Basic

<TextField>
  <TextField.Label>Courriel</TextField.Label>
  <TextField.Field
    value={email}
    onChangeText={setEmail}
    placeholder="nom@exemple.fr"
    keyboardType="email-address"
    autoCapitalize="none"
    autoComplete="email"
  />
  <TextField.Description>On ne le partage jamais.</TextField.Description>
</TextField>

Invalid

<TextField isInvalid={Boolean(error)}>
  <TextField.Label>Courriel</TextField.Label>
  <TextField.Field value={email} onChangeText={setEmail} />
  {error ? <TextField.Error>{error}</TextField.Error> : null}
</TextField>

isInvalid paints the border, the label and the description in danger, and takes the focus treatment off: an error outranks focus, and a field that is both should read as wrong rather than as busy.

It does not mount or unmount TextField.Error. You write that condition yourself and see it — a slot that silently renders nothing is a slot you cannot debug.

Multiline

<TextField.Field multiline /> works — it is the same TextInput — but the height, the vertical padding and textAlignVertical are then yours to set.

For a field that is multiline by design, reach for TextArea instead. It is this component — the same root, the same recipe, the same context, and TextArea.Label, .Description and .Error are literally the slots documented here — with a field that adds multiline, the text pinned to the top, and a height counted in lines:

<TextArea rows={3} maxRows={6}>
  <TextArea.Label>Message</TextArea.Label>
  <TextArea.Field />
</TextArea>

Disabled

<TextField isDisabled>
  <TextField.Label>Identifiant</TextField.Label>
  <TextField.Field value={login} />
</TextField>

editable is not a public prop — it is disabled under another name, and R8 keeps that off the surface. isDisabled on the root dims the column and stops the field.

As another element

<TextField asChild>
  <Animated.View layout={LinearTransition}>…</Animated.View>
</TextField>

The caller's element is the column. The slots still read the root's context.

Sizes

size drives the field's minimum height, its padding, the gaps and the type — never the width. A field with no width fills its parent in a column, which is RN's own behaviour and the reason there is no fullWidth prop.

sizeField min-heightPaddingFieldLabelDescription / Error
xs32101414/2012/16
sm40121614/2012/16
md48121616/2414/20
lg56161818/2816/24

md is the reference implementation's input measured: a 48pt minimum, 12pt of horizontal padding, a 16/24 label above the field and a 14/20 line below it.

A minimum and not a fixed height, which is the one place this component departs from the rule the Button and the Chip follow. A TextInput with multiline holds three lines of the user's own text and has to grow; a control whose content is not the developer's cannot be truncated into shape. The reference implementation reaches the same conclusion with min-height.

The label and the help text carry a small horizontal inset — half the md field's padding — so the column reads as one block. It does not scale: it is an optical alignment, not a measurement.

Variants

The library's four emphasis levels, narrowed like the Card's — and this is the first real use of the theme's field* family, the tokens P0 derived for exactly this component and nothing else has read since.

variantBackgroundBorderFocus borderShadow
primaryfieldBackgroundfieldBorderfieldBorderFocusfield
secondarydefaultfieldBorderfieldBorderFocus
tertiarytransparentfieldBorderfieldBorderFocus
ghosttransparent— (no border)

The four names split the reference implementation's two-name primary | secondary by saying what each of their ends already is:

  • primary is their primary — the fieldBackground fill — plus the theme's field shadow, the elevation their flat token only implies.
  • secondary is their secondary, the neutral default fill, and the default here: on a plain background a white field is its border and nothing else, while on a card the fieldBackground token is the card's own colour.
  • tertiary is the border alone, the same drop the Button's tertiary makes.
  • ghost is neither, and it has no border to move — its focus shows in the caret alone. Reach for tertiary when a focus ring matters.

The first three name the fieldBorder edge and ghost gives it up. Its width is the theme's borderWidth.field — the same knob the reference implementation exposes as --field-border-width, and the one shipped default where the two libraries differ: they ship 0, so their input is a fill with no visible edge, and we ship 1. createTheme({ borderWidth: { field: 0 } }) reproduces theirs exactly.

A field reports nothing — an error is isInvalid, which is a state and not a variant — so success, warning and danger are absent here exactly as they are on the Card.

Label placement

<TextField labelPlacement="inside">
  <TextField.Label>Courriel</TextField.Label>
  <TextField.Field placeholder="nom@exemple.fr" />
  <TextField.Description>On ne le partage jamais.</TextField.Description>
</TextField>

outside (the default) leaves the label above the box, in the column's flow. inside lifts it into the box, above the text: the label is taken out of flow and placed against the box's own padding, so the JSX is identical either way and nothing is reparented (R4). The field then pays for the room — paddingTop clears the line and the box grows by the same amount, so the text keeps the height its size promised.

Because the inside label positions itself against the top of the root, it assumes the field is the first thing left in the column's flow. Write TextField.Description and TextField.Error after the field, which is where they belong anyway.

sizeOutside min-heightInside min-heightInside label
xs324812/16
sm405212/16
md485212/16
lg566014/20

The inside height is built from the two lines the box now holds rather than added to the control height — controlHeights already pays for centring one line — and floored at the control height, so a theme that raises controlHeights never ends up with an inside field shorter than the outside one beside it.

Focus

The border darkens towards the mode's ink — fieldBorderFocus, which is fieldBorder mixed 26% towards fieldForeground. No ring and no accent: a form where every focused field flashes the brand colour is a form where the accent has stopped meaning "the action".

The focus state lives on the root, because the recipe resolves on it (R5), while the node that hears the event is TextField.Field — the field composes the caller's onFocus and onBlur with the two the context published, so your handlers run and the border still moves.

Alignment with the reference implementation

Measured against their input.css, text-field.css, label.css, description.css, field-error.css and variables.css rather than eyeballed.

Identical at md: the 4pt spacing unit, the 12/16 · 14/20 · 16/24 · 18/28 type scale (Tailwind v4's defaults, which they use), the 48pt minimum, 12pt of horizontal padding, the 6pt column gap, the 6pt inset on the label and the help text, a 16/24 medium label in foreground, a 14/20 muted description, a 14/20 danger error, a 400 field text and the field-placeholder colour. The whole radius scale shares their multipliers (0.25 · 0.5 · 0.75 · 1 · 1.5 · 2 · 3 · 4) and the field radius is base × 1.75 in both.

We read fieldForeground where they read foreground; the two resolve to the same value in both modes, and the field token is the one that can be themed apart later.

Two deltas, both deliberate and both in the theme rather than in this component:

TokenThe reference implementationXAUIWhy
borderWidth.field01Their input is a fill with no visible edge. Ours keeps a hairline, because tertiary — the border alone — has nothing left to be without it. Same knob, different shipped default: createTheme({ borderWidth: { field: 0 } }) reproduces theirs.
radius base8 → field 1412 → field 21RADIUS_BASE is a P0 decision that draws every corner in the library. Changing it for one component would be incoherent; changing it globally is a theme decision, not this one.

Two things we add that they do not have: a focus state — their CSS has none, and the theme derived fieldBorderFocus for it — and the field shadow on primary, which is what makes it read as raised rather than as a second flat fill.

Colour

<TextField variant="tertiary" color="#7c3aed">
  <TextField.Field placeholder="la teinte est la bordure, et le focus" />
</TextField>

A raw tint, never a token (R7). It lands where the variant put its tokens, and because the focus colour is a role like any other it is also what the field borders on focus.

On a primary or a secondary the tint is the fill — a solid coloured box with contrasted text, consistent with a tinted Button and rarely what a form wants. tertiary and ghost are where a tint is useful on a field.

Style as props

Every node takes its own style keys as props (R14) — full React Native names, full React Native values, no hidden scale:

<TextField maxWidth={420}>
  <TextField.Label letterSpacing={0.4}>Courriel</TextField.Label>
  <TextField.Field borderWidth={2} textAlign="center" />
</TextField>

Everything else goes through style

  • selectionColor, cursorColor, placeholderTextColor are TextInput props — write them on TextField.Field. The placeholder already takes the theme's fieldPlaceholder; the prop is there to override it.

  • A leading or trailing adornment — a search glyph, a clear button, a unit — is FieldGroup. It goes where the field goes and replaces nothing else:

    <TextField>
      <TextField.Label>Recherche</TextField.Label>
      <FieldGroup>
        <FieldGroup.Prefix isDecorative>
          <FieldGroup.Icon as={SearchIcon} />
        </FieldGroup.Prefix>
        <FieldGroup.Field placeholder="Rechercher…" />
      </FieldGroup>
    </TextField>
    

Root props

TextFieldProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
variantTextFieldVariant | undefinedNo
sizeSize | undefinedNoThe field's height, its padding, the gaps and the type. Never width.
radiusRadiusKey | undefinedNoOverrides the `field` radius the theme chose for every size.
labelPlacementTextFieldLabelPlacement | undefinedNoAbove the box, or lifted into it.
colorstring | undefinedNoA raw tint (`'#7c3aed'`), never a token (R7). It lands where the variant put its tokens — the fill of a `default`, the border of a `tertiary` — and, because the focus colour is a role like any other, it is also what the field borders on focus.
isInvalidboolean | undefinedNoPaints the border, the label and the description in `danger`, and takes the focus treatment off: an error outranks focus, and a field that is both should read as wrong rather than as busy. It does **not** mount or unmount `TextField.Error`. That stays the caller's — a slot that silently renders nothing is a slot you cannot debug.
isDisabledboolean | undefinedNoDims the field and makes it uneditable.
styleStyleProp<ViewStyle>No
childrenReactNodeNo
asChildboolean | undefinedNoR12 — merge into the single child instead of rendering a `View`.

Slots

TextFieldDescriptionProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

TextFieldErrorProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

TextFieldFieldProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

This slot adds nothing to the props of its React Native node.

TextFieldLabelProps

The node's inherited React Native props apply too, and so do its style props padding, margin, width and the rest.

PropTypeRequiredDescription
childrenReactNodeNo

Variants, sizes and colour

This component adds no visual axis of its own. The values it does take are in the generated types above and in the live demo.

Accessibility

  • The wrapper has no role. The control is the field inside it, and a role on the column would give a screen reader a second element to stop on before reaching it.
  • TextField.Field points at the label with aria-labelledby and at the description with aria-describedby, so a screen reader announces "Courriel, champ de saisie" instead of falling back to whatever the placeholder happens to say.
  • aria-invalid follows isInvalid, and accessibilityState.disabled follows isDisabled.
  • TextField.Error sets no live region, deliberately: an error that changes while you are still typing would be re-announced on every keystroke. The field's aria-invalid is what reports the state.
  • A placeholder is not a label. A field with only a placeholder loses its name the moment a value is typed — write a TextField.Label, and hide it with <TextField.Label height={0} opacity={0}> only if the design truly cannot show one.

Migration from legacy

The legacy component is TextInput, and its props are the ones this table names.

Legacyv1
label="…"<TextField.Label>…</TextField.Label>
description="…"<TextField.Description>…</TextField.Description>
errorMessage="…"<TextField.Error>…</TextField.Error>, mounted by you, plus isInvalid
value / defaultValuethe same two props, on <TextField.Field>
onValueChangeonChangeText on <TextField.Field> — RN's name. onChange works too
labelPlacement="inside"labelPlacement="inside" — a static label, not a floating one
variant="colored"variant="primary"
variant="light"variant="secondary"
variant="bordered"variant="tertiary"
variant="underlined"variant="ghost" + borderBottomWidth on <TextField.Field>
themeColor="primary"color={theme.colors.accent}
size="sm" | "md" | "lg"size — now xslg, and the legacy sm is the new xs
radiusradius — a RadiusKey now, not the legacy Radius
isSecuredsecureTextEntry on <TextField.Field> — RN's own name
isReadOnlyreadOnly on <TextField.Field>
isDisabled / isInvalidunchanged, on the root
isClearablea Pressable in a <FieldGroup.Suffix> that sets the value to ''
TextArea with minRows / maxRows<TextField.TextArea rows maxRows /> — a slot, not a component
fullWidthremoved — that is already the default in a column
startContent / endContent<FieldGroup.Prefix> / <FieldGroup.Suffix> around the field
customAppearance={{ container }}style on the root
customAppearance={{ input }}style on <TextField.Field>
customAppearance={{ label }}style on <TextField.Label>
customAppearance={{ helperText }}style on <TextField.Description> / <TextField.Error>
customAppearance={{ inputContainer, inputWrapper }}gone with the wrappers they styled

Implementation notes

Extending it

The context hook is exported, so a third party can write their own slot against the same resolved values the built-in ones read:

import { useTextField } from '@xaui/native/text-field'

function TextFieldCounter({ length, max }) {
  const { descriptionStyle } = useTextField()
  return (
    <Text style={descriptionStyle}>
      {length} / {max}
    </Text>
  )
}

Used outside a <TextField> it throws by name, pointing at the misplaced component rather than failing three frames later on an undefined style.