Skip to content
All components

Free reference

React Native Text Field

A React Native text field is a TextInput wrapped with a label, an error line and focus styling, because React Native core only ships the bare input.

Installation

Only Reanimated, and only for the label. The field itself is core React Native. Reanimated 4 runs its worklets through react-native-worklets, so install both.

npx expo install react-native-reanimated react-native-worklets

Usage

Paste this into components/TextField.tsx. Every prop you do not name is passed straight to the TextInput, so keyboard and autofill settings work as usual.

// TextField.tsx
import { useRef, useState, type ComponentProps } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import Animated, {
  interpolate,
  useAnimatedStyle,
  useDerivedValue,
  withTiming,
} from 'react-native-reanimated';

type TextFieldProps = Omit<
  ComponentProps<typeof TextInput>,
  'value' | 'onChangeText' | 'style'
> & {
  label: string;
  value: string;
  onChangeText: (text: string) => void;
  error?: string;
};

export function TextField({
  label,
  value,
  onChangeText,
  error,
  onFocus,
  onBlur,
  ...inputProps
}: TextFieldProps) {
  const inputRef = useRef<TextInput>(null);
  const [focused, setFocused] = useState(false);
  const raised = focused || value.length > 0;

  const progress = useDerivedValue(
    () => withTiming(raised ? 1 : 0, { duration: 160 }),
    [raised],
  );

  const labelStyle = useAnimatedStyle(() => ({
    transform: [
      { translateY: interpolate(progress.value, [0, 1], [0, -13]) },
      { scale: interpolate(progress.value, [0, 1], [1, 0.76]) },
    ],
  }));

  return (
    <View>
      <Pressable
        onPress={() => inputRef.current?.focus()}
        accessible={false}
        style={[
          styles.box,
          focused && styles.boxFocused,
          Boolean(error) && styles.boxError,
        ]}
      >
        <Animated.Text
          pointerEvents="none"
          numberOfLines={1}
          style={[styles.label, labelStyle]}
        >
          {label}
        </Animated.Text>

        <TextInput
          {...inputProps}
          ref={inputRef}
          value={value}
          onChangeText={onChangeText}
          onFocus={(event) => {
            setFocused(true);
            onFocus?.(event);
          }}
          onBlur={(event) => {
            setFocused(false);
            onBlur?.(event);
          }}
          accessibilityLabel={label}
          accessibilityHint={error}
          style={styles.input}
        />

        {focused && value.length > 0 ? (
          <Pressable
            onPress={() => onChangeText('')}
            hitSlop={12}
            accessibilityRole="button"
            accessibilityLabel={`Clear ${label}`}
            style={styles.clear}
          >
            <Text style={styles.clearGlyph}>×</Text>
          </Pressable>
        ) : null}
      </Pressable>

      {/* Fixed height, so a new error never pushes the form down. */}
      <View style={styles.errorRow}>
        {error ? (
          <Text accessibilityLiveRegion="polite" style={styles.error}>
            {error}
          </Text>
        ) : null}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  box: {
    height: 58,
    justifyContent: 'flex-end',
    borderRadius: 12,
    borderWidth: 1.5,
    borderColor: '#E4E4E7',
    backgroundColor: '#FFFFFF',
    paddingHorizontal: 14,
    paddingBottom: 8,
  },
  boxFocused: { borderColor: '#18181B' },
  boxError: { borderColor: '#D92D20' },
  label: {
    position: 'absolute',
    left: 14,
    right: 14,
    top: 18,
    fontSize: 16,
    color: '#71717A',
    transformOrigin: 'left center',
  },
  input: {
    height: 24,
    padding: 0,
    paddingRight: 28,
    fontSize: 16,
    color: '#18181B',
    includeFontPadding: false,
  },
  clear: {
    position: 'absolute',
    right: 12,
    top: 16,
    width: 22,
    height: 22,
    borderRadius: 11,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#E4E4E7',
  },
  clearGlyph: { fontSize: 15, lineHeight: 17, color: '#52525B' },
  errorRow: { minHeight: 20, justifyContent: 'center' },
  error: { fontSize: 13, lineHeight: 18, color: '#D92D20' },
});

Then set the props for what the field actually collects:

<TextField
  label="Email"
  value={email}
  onChangeText={setEmail}
  error={emailError}
  keyboardType="email-address"
  autoCapitalize="none"
  autoCorrect={false}
  autoComplete="email"
  textContentType="emailAddress"
  returnKeyType="next"
/>

How it works

TextInput has no label, error or focus state of its own, so a text field is a small wrapper that owns those three things. The input stays controlled by the parent. The wrapper only tracks whether it has focus.

The label sits over the input and moves on one rule: it is raised whenever the field is focused or has a value. Scale and translate run on the UI thread, and the label ignores touches, so a tap on it reaches the box behind it, which focuses the input. transformOrigin is what stops the shrinking label drifting toward the centre of the box.

The error row is always there at a fixed minimum height, so showing a message moves nothing. The same trick is explained in more depth in form validation with Zod and react-hook-form, which plugs into this component by passing fieldState.error?.message as error.

Gotchas

Text sits lower on Android

Android reserves extra space above and below the glyphs, so the same style renders a few pixels low. includeFontPadding: false and padding: 0 remove it, which is why the input sets both and lets the wrapper handle spacing.

Don't set lineHeight on a single-line input

On iOS a lineHeight on a single-line TextInput tends to shift the text vertically inside the field. Set a fixed height instead, and keep lineHeight for multiline text areas.

autoComplete and textContentType are not the same thing

autoComplete works across platforms, while textContentType is iOS only and is what drives the QuickType bar. Set both on anything the OS could fill, like email, name and phone.

The keyboard covers the field

KeyboardAvoidingView needs different behaviour per platform and often fights tab bars. react-native-keyboard-controller ships in Expo Go and handles a whole form with one KeyboardAwareScrollView.

Accessibility

The floating label is decoration to a screen reader, so the name has to come from accessibilityLabel on the input. The error goes in accessibilityHint so it is read with the field, and accessibilityLiveRegion announces new errors on Android. iOS has no live regions, so call AccessibilityInfo.announceForAccessibility when a submit fails.

When to use a library instead

If you already use React Native Paper, its TextInput gives you a Material floating label for free. If you want a truly native control on each platform, @expo/ui exposes the SwiftUI and Jetpack Compose fields. Use this file when the field has to match your own design and you would rather not add a UI kit for one input. If the field picks from a list instead of taking text, you want a dropdown.

Does React Native have a text field component?

React Native ships TextInput, which is the bare editable field. It has no label, helper text or error state, so what most design systems call a text field is a TextInput wrapped in a View with those parts added, as in the component on this page.

How do I style a TextInput in React Native?

Pass a style prop for the box and the text, such as height, borderWidth, borderRadius, paddingHorizontal, fontSize and color. The placeholder has its own placeholderTextColor prop, and the caret colour is selectionColor. For focus styling, keep a focused boolean in state and switch the border colour from onFocus and onBlur.

How do I add a floating label in React Native without a library?

Absolutely position a label over the input and animate its translateY and scale when the field is focused or has a value. Set pointerEvents="none" on the label so taps reach the input, and transformOrigin: 'left center' so it shrinks toward its own start rather than its centre.

Why is my TextInput text cut off on Android?

Usually the input's height is smaller than the font's real line box, because Android adds font padding above and below the glyphs. Add includeFontPadding: false and padding: 0, then give the input enough height for its fontSize.

Depends onreact-native-reanimatedreact-native-worklets

Every component on this page is free to copy. Motionary sells the polished, production versions over at the catalog.