React Native Text Input: Floating Labels and Form States
By Native Animation · Updated
A React Native text field needs a real TextInput, a persistent label, a controlled string value, and an explicit validation policy. Float the label when the field is focused or nonempty, including when the parent supplies a value. Keep errors separate from editing so focus, cursor position, and the draft survive a failed submission.
Choose the field behavior before the animation
This tutorial builds a single-line project-name field. It handles label position, focus, disabled state, and an error after blur or submit. It does not try to replace the full TextInput reference or implement every form type. Start with the React Native 0.88 reference when choosing keyboard, autocomplete, secure-entry, and multiline behavior.
| State | Label | Editing | Validation presentation |
|---|---|---|---|
| Empty, blurred | Inside the field | Enabled | Helper text |
| Empty, focused | Floated | Enabled | Helper or existing error |
| Filled, blurred | Floated | Enabled | Validate after interaction |
| Filled, focused | Floated | Enabled | Preserve the draft and caret |
| Disabled | Follows content | Disabled | Explain why if needed |
| Invalid | Follows focus/content | Enabled | Text error and visible border |
Build a controlled text field
Install react-native-reanimated and react-native-worklets with npx expo install (bunx expo install in Bun projects). Follow the Expo SDK 58 Reanimated setup. This complete TextInputExample.tsx targets React Native 0.88.0, Reanimated 4.7.1, and Worklets 0.13.0. Place it outside the Expo Router route tree and render it from a safe-area screen with room for the software keyboard.
Focus, empty, filled, disabled, and invalid states
import { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, TextInput, View } from 'react-native';
import Animated, {
useAnimatedStyle, useReducedMotion, useSharedValue, withSpring,
} from 'react-native-reanimated';
type TextFieldProps = {
label: string;
value: string;
onChangeText: (text: string) => void;
onBlur: () => void;
disabled?: boolean;
error?: string;
};
export function TextField({ label, value, onChangeText, onBlur,
disabled = false, error }: TextFieldProps) {
const [focused, setFocused] = useState(false);
const raised = focused || value.length > 0;
const progress = useSharedValue(raised ? 1 : 0);
const reduced = useReducedMotion();
useEffect(() => {
progress.set(reduced ? Number(raised) : withSpring(Number(raised), {
stiffness: 300, damping: 30, mass: 1,
}));
}, [progress, raised, reduced]);
const labelStyle = useAnimatedStyle(() => ({
transform: [{ translateY: -13 * progress.get() }],
fontSize: 16 - 4 * progress.get(),
}));
return (
<View style={{ gap: 8 }}>
<View style={[styles.field, focused && styles.focused,
!!error && styles.invalid, disabled && styles.disabled]}>
<Animated.Text accessible={false} pointerEvents="none"
style={[styles.label, labelStyle]}>{label}</Animated.Text>
<TextInput value={value} onChangeText={onChangeText} editable={!disabled}
accessibilityLabel={label} accessibilityState={{ disabled }}
accessibilityHint={error ? 'Error: ' + error : 'Use at least three characters.'}
onFocus={() => setFocused(true)}
onBlur={() => { setFocused(false); onBlur(); }}
autoCapitalize="sentences" style={styles.input} />
</View>
<Text accessibilityLiveRegion="polite" style={error ? styles.error : styles.helper}>
{error ?? 'Use at least three characters.'}
</Text>
</View>
);
}
export function TextInputExample() {
const [name, setName] = useState('');
const [touched, setTouched] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [disabled, setDisabled] = useState(false);
const [saved, setSaved] = useState('');
const invalid = name.trim().length < 3;
const error = (touched || submitted) && invalid
? 'Enter a project name with at least three characters.' : undefined;
return (
<View style={{ padding: 24, gap: 16 }}>
<TextField label="Project name" value={name} onChangeText={setName}
onBlur={() => setTouched(true)} disabled={disabled} error={error} />
<Button title="Save project" disabled={disabled} onPress={() => {
setSubmitted(true);
if (!invalid) setSaved(name.trim());
}} />
<Button title="Load example" onPress={() => setName('Morning notes')} />
<Button title="Reset form" onPress={() => {
setName(''); setTouched(false); setSubmitted(false); setSaved('');
}} />
<Button title={disabled ? 'Enable field' : 'Disable field'}
onPress={() => setDisabled((value) => !value)} />
<Text>Saved project: {saved || 'None'}</Text>
</View>
);
}
const styles = StyleSheet.create({
field: { minHeight: 64, borderWidth: 2, borderColor: '#746B80', borderRadius: 12 },
focused: { borderColor: '#554080' },
invalid: { borderColor: '#A1243B' },
disabled: { backgroundColor: '#E9E6EC' },
label: { position: 'absolute', top: 21, left: 14, color: '#51465F' },
input: { minHeight: 60, paddingTop: 25, paddingBottom: 9, paddingHorizontal: 14,
fontSize: 16, color: '#241B30' },
error: { color: '#A1243B' },
helper: { color: '#51465F' },
});Keep the visible label independent of placeholder text
The label stays visible after typing; it is not placeholder text that disappears. The input has its own accessible name so the decorative animated copy does not become a second focus target. The teaching layout reserves vertical room for both label and value. Increase that space or keep the label above the field when larger system text would overlap; never disable font scaling to preserve a compact look.
Animate the label from one progress value
The Boolean raised condition combines focus and content. One shared value drives label position and size through useAnimatedStyle. Accepted text remains ordinary React state; the animation never owns or formats the string.
Handle external values and reset
Load example raises the label without focusing the input. Reset clears it; if the field still has focus, the label stays raised until blur. Repeated focus changes retarget the spring from its current value. Do not key the field by its text or error: remounting it would replace the native editor and can lose cursor position or keyboard focus.
Show validation at the right moment
Draft errors versus submit errors
The parent waits until blur or submit before showing the minimum-length error. After interaction, typing a valid draft clears it. This small demo validates synchronously; a production form should distinguish field feedback from a failed save request. Keep server errors attached to the correct draft or request ID, and ignore results for a value that has since changed.
Keep focus and preserve the cursor
onChangeText stores the exact text. Trimming happens only when saving the accepted project name; the editor is not reformatted during typing. Changing border or helper copy does not replace the TextInput. Avoid forcing selection to the end to fix a problem caused by rewriting the value.
The example does not automatically focus invalid fields after submit. In a longer form, keep refs and focus the first invalid field, then scroll it above the keyboard. Use the host's keyboard and safe-area layout rather than adding competing avoidance layers inside every control.
Accessibility and reduced motion
Use a visible label and a matching accessible name, readable error text, and a disabled state that matches editable. The error is also included in the input hint; the polite live region supports update announcements on Android. Test VoiceOver and TalkBack behavior in the surrounding form, especially if server feedback arrives while another field has focus. Color and animation supplement the message.
The reduced-motion hook reads the startup setting and removes label travel in this example. Subscribe to live preference changes if your app needs them. Text, errors, and save behavior remain available with motion disabled.
Use the catalog field
Floating Label Input adds a touch-origin focus light, coordinated validation feedback, and drawn status icons. Its API has value/onChangeText, error, helperText, validationState, and inputProps. After copying the folder with the CLI, a complete host example is:
import { useState } from 'react';
import { FloatingLabelInput } from './floating-label-input';
export function ProjectField() {
const [value, setValue] = useState('');
const [touched, setTouched] = useState(false);
const invalid = value.trim().length < 3;
return <FloatingLabelInput label="Project name" value={value}
onChangeText={setValue}
error={touched && invalid ? 'Use at least three characters.' : undefined}
helperText="Choose a name you can recognize later."
validationState={touched ? (invalid ? 'invalid' : 'valid') : 'idle'}
inputProps={{ onBlur: () => setTouched(true), editable: true }} />;
}Validation belongs to the host; the field displays the supplied state. Its ref provides focus, blur, and clear. Do not pass a nonexistent validator or disabled prop: use the documented inputProps for native editing behavior. The Conditional Field Morph serves a different task: adding and removing dependent form fields. Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release.
When to use a specialized input
OTP, phone, numeric, and rich text
Use the OTP guide for a verification-code flow and the number input guide for decimal drafts and commit validation. A phone field needs country context, formatting, and a dedicated validation policy. A dropdown stores a choice rather than free text; see dropdown selection.
Rich text needs a document model, selection-aware commands, and a rendering/editing architecture. A multiline TextInput remains a plain-text editor. The Expo rich-text architecture guide explains the available approaches; a floating label cannot supply that engine.
FAQ
Does placeholder text replace a label?
No. It disappears after entry and should not be the only explanation of a field. Keep a visible label and an accessible name.
What if the parent supplies a value before focus?
The nonempty value raises the label. Reset returns it inside only when the input is also blurred.
Should every keystroke show a validation error?
Choose feedback timing for the task. This example waits for blur or submit, then updates feedback as the draft changes.
Can I pass arbitrary TextInput props to the catalog field?
Use inputProps for the documented native options. The top-level value and onChangeText remain authoritative; the component omits those keys from inputProps.