Why it breaks: two platforms, two models
On iOS the keyboard is an overlay. Your app’s window stays the full height of the screen and the keyboard slides on top of it, so if you want a field to stay visible, you have to move it. That is what KeyboardAvoidingView was built for.
On Android with adjustResize, the window itself shrinks. Your root view is genuinely shorter, flex re-lays-out, and the field is already visible before any React code runs. Add behavior="padding"on top of that and you subtract the keyboard height twice — the classic “huge empty gap above the keyboard” screenshot.
| behavior | iOS | Android | What it does |
|---|---|---|---|
padding | ✅ The default choice | ⚠️ Double-counts with resize | Adds bottom padding equal to the keyboard height |
height | ⚠️ Jumpy with animations | ✅ Works if resize is off | Shrinks the container itself |
position | ⚠️ Rarely right | ❌ Avoid | Shifts absolutely — breaks flex layouts underneath |
undefined | ❌ Does nothing | ✅ Correct with adjustResize | Lets the OS do the work |
Which is why the line you see in every real codebase is the conditional one, and why copying a snippet that hardcodes "padding"works perfectly on the reviewer’s iPhone and looks broken on half your users’ phones.
The setup that works
import { useHeaderHeight } from '@react-navigation/elements';
import {
KeyboardAvoidingView, Platform, ScrollView,
StyleSheet, TextInput, Pressable, Text,
} from 'react-native';
export default function SignUpScreen() {
const headerHeight = useHeaderHeight(); // 0 if the screen has no header
return (
<KeyboardAvoidingView
style={styles.fill}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={headerHeight}
>
<ScrollView
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
>
<TextInput style={styles.input} placeholder="Email"
keyboardType="email-address" autoCapitalize="none" />
<TextInput style={styles.input} placeholder="Password" secureTextEntry />
<Pressable style={styles.button} onPress={submit}>
<Text style={styles.buttonText}>Create account</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
content: { flexGrow: 1, justifyContent: 'center', padding: 24, gap: 12 },
input: { backgroundColor: '#141414', borderRadius: 12, padding: 16, color: '#fff' },
button: { backgroundColor: '#fb923c', borderRadius: 12, padding: 16, alignItems: 'center' },
buttonText: { color: '#1c1c1c', fontWeight: '700' },
});Four things in there are load-bearing, and each one corresponds to a bug report you would otherwise file:
- Order.
KeyboardAvoidingViewwraps theScrollView, never the reverse. Inside a scroll view it has no fixed height to compute against and quietly does nothing. keyboardVerticalOffset. The component measures from its own top, which sits below the navigation header. Without the offset it avoids by the full keyboard height and overshoots by exactly the header height.keyboardShouldPersistTaps="handled". This is the fix for “my submit button needs two taps”. Without it, the first tap is consumed dismissing the keyboard.flexGrow: 1on the content container. Lets a short form centre itself while a long one still scrolls. Settingflex: 1there instead caps the content at screen height and breaks scrolling.
The Android config you also need
None of the above helps on Android if the window is not resizing. In Expo, set it in the app config:
{
"expo": {
"android": {
"softwareKeyboardLayoutMode": "resize"
}
}
}In a bare project this is android:windowSoftInputMode="adjustResize" on the activity in AndroidManifest.xml. It is a native change, so it needs a rebuild — editing it and reloading JavaScript does nothing, which sends a lot of people back to fiddling with behavior values that were already correct.
One more recent wrinkle: from Android 15, edge-to-edge display is enforced, and an app drawing behind the system bars does not get the same automatic resize it used to. If keyboard handling that worked last year regressed after an SDK upgrade, this is the first thing to check — and it is a good reason to reach for a library that handles insets and keyboard together rather than patching per-screen.
When to stop and use keyboard-controller
react-native-keyboard-controller tracks the keyboard frame on the UI thread and exposes it as a Reanimated value, so content moves with the keyboard rather than snapping after it. It also makes both platforms behave identically, which removes the entire class of bug this article is about.
npx expo install react-native-keyboard-controller
npx expo run:ios # needs a development build — not in Expo GoTake it when you have a chat composer pinned to the bottom, a multi-step form, a screen where the keyboard animation is visible enough to look cheap, or a sheet with inputs in it. Stay with the built-in component for a two-field login screen — the extra native dependency is not worth it, and KeyboardAvoidingView genuinely handles that case.
Whatever you pick, test the real failure case: the last field of the longest form, on the smallest screen you support, on a physical device. Simulators are forgiving here in ways that phones are not.
Get it right once, then reuse it
Keyboard handling is per-screen boilerplate that has exactly one correct shape, so the sane move is to write a FormScreen wrapper once and never think about behavior again. Every input screen in the app gets the same offsets, the same tap handling, and the same Android config.
That is also what a generator should be doing for you. ShipNative builds form screens with the platform-conditional wrapper and persist-taps already wired, and previews them on a real device so you can see the keyboard behaviour before you write any code. Pair it with the form validation setup and the whole category of form bugs mostly disappears.