+1 (415) 943-4271

Forms That Don't Fight the Keyboard: Keyboard Handling and Form UX in React Native

Almost every app we are brought into has at least one screen where the keyboard covers the thing the user is typing into. Sign-up, checkout, address entry, a support message box — the form is usually the highest-value screen in the product, and it is usually the jankiest.

The reason is that keyboard handling in React Native has quietly changed. KeyboardAvoidingView was always a blunt instrument, and under Android edge-to-edge enforcement it is now frequently wrong rather than merely imprecise. This tutorial is how we build form screens on client projects today.

Why KeyboardAvoidingView stopped being enough

Three things broke the old recipe:

  1. Platform divergence. behavior="padding" on iOS, "height" on Android, plus a manual keyboardVerticalOffset tuned per header — that is three magic numbers per screen, and every one of them drifts.
  2. Edge-to-edge on Android. With the system no longer reserving space for bars, android:windowSoftInputMode="adjustResize" no longer behaves the way the old guides assume. The keyboard is just another window inset now, and you have to read it as one.
  3. No interactive tracking. The built-in component snaps. It cannot follow a swipe-to-dismiss gesture, so the UI visibly lags the keyboard on iOS.

Step 1: install a real keyboard primitive

react-native-keyboard-controller is the library we standardise on. It exposes the keyboard as an animated value driven from the native side, which means your layout can move with the keyboard frame-by-frame instead of after it.

npx expo install react-native-keyboard-controller react-native-reanimated

Wrap the app once:

import { KeyboardProvider } from 'react-native-keyboard-controller';

export default function App() {
  return (
    <KeyboardProvider statusBarTranslucent navigationBarTranslucent>
      <RootNavigator />
    </KeyboardProvider>
  );
}

If you are on Expo with continuous native generation, add the config plugin and rebuild — this is not an OTA-safe change, so it needs to ride a store build or a new dev client.

Step 2: replace the avoiding view

For most screens, one component does the job:

import {
  KeyboardAwareScrollView,
} from 'react-native-keyboard-controller';

function CheckoutScreen() {
  return (
    <KeyboardAwareScrollView
      bottomOffset={24}
      contentContainerStyle={{ padding: 16, gap: 16 }}
      keyboardShouldPersistTaps="handled"
    >
      <AddressFields />
      <CardFields />
    </KeyboardAwareScrollView>
  );
}

Two details matter more than they look:

  • bottomOffset is the breathing room between the focused input and the top of the keyboard. Set it once at a sensible value (20–32) rather than per screen.
  • keyboardShouldPersistTaps="handled" is the fix for the classic "I had to tap the button twice" bug, where the first tap only dismisses the keyboard.

For a sticky footer CTA that should ride above the keyboard, use KeyboardStickyView rather than absolutely positioning it and hoping:

<KeyboardStickyView offset={{ closed: 0, opened: 12 }}>
  <PrimaryButton title="Pay" onPress={submit} />
</KeyboardStickyView>

Step 3: get the insets right under edge-to-edge

The keyboard is an inset, and so are the status and navigation bars. Mixing a safe-area bottom padding with a keyboard offset is how you end up with a 34pt gap on iPhone when the keyboard is open.

The rule we apply: when the keyboard is visible, it replaces the bottom safe-area inset — it does not add to it.

import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useKeyboardHandler } from 'react-native-keyboard-controller';
import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';

function useBottomPadding() {
  const insets = useSafeAreaInsets();
  const height = useSharedValue(0);

  useKeyboardHandler({
    onMove: (e) => {
      'worklet';
      height.value = e.height;
    },
  }, []);

  return useAnimatedStyle(() => ({
    paddingBottom: Math.max(height.value, insets.bottom),
  }));
}

Because the handler is a worklet it runs on the UI thread, so the padding tracks an interactive dismiss gesture instead of jumping at the end of it.

Step 4: make focus movement predictable

Users expect the return key to walk them down the form. Wire it explicitly instead of relying on the platform:

const emailRef = useRef(null);
const passwordRef = useRef(null);

<TextInput
  ref={emailRef}
  returnKeyType="next"
  submitBehavior="submit"
  onSubmitEditing={() => passwordRef.current?.focus()}
  keyboardType="email-address"
  autoCapitalize="none"
  autoComplete="email"
  textContentType="emailAddress"
/>
<TextInput
  ref={passwordRef}
  returnKeyType="done"
  secureTextEntry
  autoComplete="current-password"
  textContentType="password"
  onSubmitEditing={handleSubmit}
/>

Note submitBehavior — it replaced the old blurOnSubmit prop and is the version that reads correctly ("submit" keeps focus, "blurAndSubmit" dismisses). On newer React Native versions the keyboard-controller toolbar (<KeyboardToolbar />) gives you a free "previous / next / done" accessory bar above the keyboard, which is worth adding to any form over about four fields.

Autofill and passkeys are part of form UX

The autoComplete, textContentType and importantForAutofill props are the difference between a 10-second sign-up and a 90-second one. Pair them with oneTimeCode / sms-otp on your verification field so OTPs autofill, and if you have already moved to passkeys, put the passkey button above the password field, not below it.

Step 5: validation that doesn't shout

For state, react-hook-form with a Zod resolver is still the lowest-friction option in React Native, because uncontrolled-style registration keeps keystrokes off the re-render path:

const { control, handleSubmit, formState: { errors, isSubmitting } } =
  useForm({ resolver: zodResolver(schema), mode: 'onTouched' });

mode: 'onTouched' is deliberate. Validating onChange means the user sees "invalid email" after typing the first character, which reads as the app being annoyed with them. Validate when they leave the field, re-validate on change only once a field has errored.

Three more things that come up in every review we do:

  • Never block submit on a disabled button alone. Disable it while isSubmitting, but on validation failure let the tap through and scroll to the first error — otherwise users on a long form cannot tell why nothing happened.
  • Announce errors to screen readers. Set accessibilityLabel on the input to include the error text, or use AccessibilityInfo.announceForAccessibility on submit failure.
  • Keep server errors in the same place as client errors. setError('email', { message }) from the API response, so there is one error surface, not two.

Step 6: QA checklist before you call a form done

We run this on every form screen, on device, not simulator:

  • Small Android phone with a three-button nav bar, and again with gestures.
  • iPhone with a home indicator, keyboard opened via tap and via autofill.
  • External/Bluetooth keyboard connected on iPad — the software keyboard never appears, so any layout that assumes a non-zero keyboard height must not break.
  • Largest Dynamic Type / Android font scale 1.3+ — does the focused field still clear the keyboard?
  • Landscape, where the keyboard eats most of the screen.
  • Interactive dismiss: drag the keyboard down slowly; the sticky footer should track the drag, not jump at the end.
  • Rotate while focused, then confirm the scroll position still shows the focused input.
  • Background and restore the app with the keyboard open.

When to keep it simple

Not every screen needs this. A single search field in a header, or a one-line comment box pinned to the bottom of a chat, is often better served by KeyboardStickyView alone or even nothing at all. The complexity is worth paying on multi-field forms — onboarding, checkout, address, profile — where the cost of a bad keyboard experience is measured in abandoned sessions.

Need a second pair of eyes?

Form and keyboard bugs are the kind of thing teams live with for months because each individual instance looks small. If your sign-up or checkout funnel is leaking users on mobile, our React Native consultants can audit the flow and hand back a prioritised fix list. Get in touch.