Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 8 min read

React Native KeyboardAvoidingView: Make It Actually Work

KeyboardAvoidingView has a reputation for not working, and the reputation is unfair in a specific way: it works exactly as documented, and the documentation does not say that iOS and Android solve this problem at completely different layers. iOS floats the keyboard over your app and expects you to move; Android resizes the window and expects you to do nothing. Almost every bug here is one of those two assumptions applied on the wrong platform. Here is the setup that survives both.

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.

behavioriOSAndroidWhat it does
padding✅ The default choice⚠️ Double-counts with resizeAdds bottom padding equal to the keyboard height
height⚠️ Jumpy with animations✅ Works if resize is offShrinks the container itself
position⚠️ Rarely right❌ AvoidShifts absolutely — breaks flex layouts underneath
undefined❌ Does nothing✅ Correct with adjustResizeLets 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. KeyboardAvoidingView wraps the ScrollView, 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: 1 on the content container. Lets a short form centre itself while a long one still scrolls. Setting flex: 1 there 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 Go

Take 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.

Frequently Asked Questions

What behavior should I use for KeyboardAvoidingView?

Use "padding" on iOS and either "height" or nothing at all on Android. On Android the system already resizes the window when windowSoftInputMode is adjustResize, so a second layer of avoidance double-counts the keyboard and pushes content too far. The standard line is behavior={Platform.OS === "ios" ? "padding" : undefined}.

Why does KeyboardAvoidingView leave a gap or push content too far?

Almost always keyboardVerticalOffset. The component measures from its own top edge, so any navigation header, tab bar, or safe-area inset above it has to be subtracted manually. Pass the header height as keyboardVerticalOffset — useHeaderHeight() from React Navigation returns the right number — otherwise the view avoids by the full keyboard height and overshoots by exactly the header.

Why does KeyboardAvoidingView do nothing on Android?

Either windowSoftInputMode is set to adjustPan instead of adjustResize, or your app is edge-to-edge and the window no longer resizes for the keyboard. On Android 15 and above edge-to-edge is enforced, which broke a lot of previously working layouts. In Expo, set android.softwareKeyboardLayoutMode in the app config, and verify on a real device — the emulator keyboard behaves differently.

Should I use KeyboardAvoidingView or react-native-keyboard-controller?

KeyboardAvoidingView is fine for a login screen with two inputs. Use react-native-keyboard-controller when you have a chat input pinned to the bottom, a long form, or anything that should track the keyboard as it animates. It runs on the UI thread and gives identical behaviour on both platforms, at the cost of a development build since it is not in Expo Go.

How do I dismiss the keyboard when tapping outside an input?

Wrap the screen in a Pressable with accessible={false} and call Keyboard.dismiss() in onPress, or set keyboardShouldPersistTaps="handled" on your ScrollView so taps on buttons work on the first press instead of only closing the keyboard. Without that prop, the first tap on a submit button is swallowed by the keyboard dismissal — the single most reported "my button needs two taps" bug.

Does KeyboardAvoidingView work inside a ScrollView?

Put it the other way around: KeyboardAvoidingView on the outside with flex: 1, ScrollView inside it. Nesting the avoiding view inside a scroll view gives it no stable height to work from, which is why it appears to do nothing. Add contentContainerStyle={{ flexGrow: 1 }} so short forms still fill the screen.

→

React Native Form Validation

The other half of a form screen that does not fight the user.

Read guide →
→

React Native Bottom Sheet

Keyboard handling inside a sheet is its own problem — solved here.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.