Free referenceMotionary
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.