react-native-keyboard-lift
v1.0.2
Published
Zero-configuration React Native keyboard management that keeps the focused input visible on iOS and Android. No native code, no runtime dependencies.
Downloads
45
Readme
react-native-keyboard-lift
Zero-configuration keyboard management for React Native. Focused inputs stay visible, on both platforms, without native code and without a single runtime dependency.
<KeyboardAwareScrollView>
<TextInput placeholder="Name" />
<TextInput placeholder="Email" />
<TextInput placeholder="Phone" />
</KeyboardAwareScrollView>That is the whole setup. No behavior prop to guess at, no
keyboardVerticalOffset to tune per screen, no platform branches.
Why another keyboard library
Because React Native's raw keyboard events do not mean the same thing on both platforms, and almost every keyboard bug traces back to that.
Reading ReactRootView.java in React Native 0.85:
int height = imeInsets.bottom - barInsets.bottom; // AndroidAndroid reports the keyboard height with the system bars already subtracted. iOS reports the full height from the bottom of the screen. Use the same number on both and your input sits 24–48pt too low on Android — which is every Android 15+ device, where edge-to-edge is mandatory and the window is no longer resized for you.
There is a second, quieter trap:
int screenY = softInputMode == SOFT_INPUT_ADJUST_NOTHING
? mVisibleViewArea.bottom - height
: mVisibleViewArea.bottom; // NOT the keyboard topOn iOS endCoordinates.screenY is the top of the keyboard. On Android it is
the bottom of the visible frame. Anything treating that field uniformly is
wrong on one platform.
This library normalises both before doing anything else, and exposes the result
through useKeyboard().
How it compares
| | This library | KeyboardAvoidingView | keyboard-aware-scroll-view | keyboard-controller |
| ---------------------------- | :----------: | :--------------------: | :--------------------------: | :-------------------: |
| Native code required | No | — | No | Yes |
| Runtime dependencies | None | — | 2 | Reanimated ≥3 |
| Works in Expo Go | Yes | Yes | Yes | No |
| Scrolls focused input | Yes | No | Yes | Yes |
| Correct under edge-to-edge | Yes | No | No | Yes |
| Actively maintained | Yes | Yes | No — last publish 2022 | Yes |
| Frame-synced Android animation | No | No | No | Yes |
Use react-native-keyboard-controller instead if you need per-frame
animation on Android or interactive-dismissal tracking. Those require
WindowInsetsAnimationCallback, which is unreachable from JavaScript, and it
is an excellent library. This one exists for the case where you want correct
behaviour without adding native code, a Reanimated peer, or a prebuild step.
Installation
npm install react-native-keyboard-liftyarn add react-native-keyboard-liftNo linking, no pods, no config plugin.
Recommended companion
npm install react-native-safe-area-contextOptional, but strongly recommended on Android. It is the only reliable
source of the navigation-bar inset, which is required to know how much of the
window the keyboard actually covers under edge-to-edge. Without it the library
falls back to a heuristic that returns 0 in edge-to-edge mode, and inputs may
sit slightly too low.
If you already use React Navigation or Expo, you almost certainly have it.
import { SafeAreaProvider } from 'react-native-safe-area-context';
export default function App() {
return (
<SafeAreaProvider>
<YourApp />
</SafeAreaProvider>
);
}Quick start
A form that scrolls
import { KeyboardAwareScrollView } from 'react-native-keyboard-lift';
function ContactForm() {
return (
<KeyboardAwareScrollView contentContainerStyle={{ padding: 20 }}>
<TextInput placeholder="Name" />
<TextInput placeholder="Email" />
<TextInput placeholder="Message" multiline />
</KeyboardAwareScrollView>
);
}A form that does not scroll
import { KeyboardAvoider } from 'react-native-keyboard-lift';
function LoginScreen() {
return (
<KeyboardAvoider style={{ flex: 1, justifyContent: 'center', padding: 20 }}>
<TextInput placeholder="Email" />
<TextInput placeholder="Password" secureTextEntry />
<Button title="Sign in" onPress={signIn} />
</KeyboardAvoider>
);
}A pinned action button
import {
KeyboardAwareFooter,
KeyboardAwareLayout,
KeyboardAwareScrollView,
} from 'react-native-keyboard-lift';
function CheckoutScreen() {
return (
<KeyboardAwareLayout>
<KeyboardAwareScrollView>
<PaymentFields />
</KeyboardAwareScrollView>
<KeyboardAwareFooter style={{ padding: 16 }}>
<Button title="Continue" onPress={submit} />
</KeyboardAwareFooter>
</KeyboardAwareLayout>
);
}The footer clears the keyboard on its own. What it cannot know is that it is drawn over the scroll view once it lifts: it moves on a transform, so the scroll view is covered without being shortened, and on its own that scroll view would put a focused field just above the keyboard — which is exactly where the button now is.
KeyboardAwareLayout is what closes that gap. It is the
<View style={{ flex: 1 }}> you would have written, not an extra layer: inside
it the footer publishes its height, and the scrollable both keeps that strip
clear and extends its content padding to match. Without it the two still work
and simply cannot see each other, which leaves the last field hidden behind the
footer rather than behind the keyboard.
The padding you set is kept: the safe-area inset is added to it while the keyboard is closed, and released when it opens, so the gap above the keyboard is the one you asked for.
API
The public surface is five components and two hooks. Everything else is an implementation detail.
<KeyboardAwareScrollView>
Accepts every ScrollView prop, plus the shared options below. Two
defaults are changed:
| Prop | Default here | React Native default | Why |
| ---- | ------------ | -------------------- | --- |
| keyboardShouldPersistTaps | 'handled' | 'never' | So the first tap on a button presses it instead of only dismissing the keyboard. |
| scrollEventThrottle | 16 | 0 | So the tracked scroll position stays accurate. |
Both are overridable.
<KeyboardAwareFlatList>
Accepts every FlatList prop, including item generics:
<KeyboardAwareFlatList<User>
data={users}
keyExtractor={(user) => user.id}
renderItem={({ item }) => <UserRow user={item} />}
ListHeaderComponent={<Header />}
refreshControl={<RefreshControl refreshing={busy} onRefresh={reload} />}
/>inverted lists are supported in the sense that they keep working — automatic
scrolling stands down for them, because inversion reverses the meaning of a
scroll offset. A development warning explains this once.
Shared options
Available on both scrollable components.
| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| enabled | boolean | true | Master switch. When off, behaves exactly like the component it wraps. |
| extraSpace | number | 12 | Gap left between the input and the keyboard. |
| scrollBehavior | 'minimal' \| 'center' | 'minimal' | 'minimal' moves the least distance that reveals the input; 'center' centres it in the remaining space. |
| resetScrollOnHide | boolean \| { x, y } | false | true returns to the offset from before the keyboard opened; an object scrolls to those coordinates. |
| applyKeyboardPadding | boolean | true | Extends the content while the keyboard is open so bottom fields are reachable at all. Turn off if you add your own inset. |
| trackFocusChanges | boolean | true | Watch for focus moving between inputs while the keyboard stays open. See focus detection. |
| bottomInset | number | auto | Override the resolved safe-area inset. Rarely needed. |
| bottomObstruction | number | auto | Points of the container's bottom edge covered by something that is not the keyboard. Resolved from a surrounding KeyboardAwareLayout; set it only for an obstruction the layout cannot see. |
| onKeyboardChange | (m: KeyboardMetrics) => void | — | Called when normalised geometry changes. |
| onScrollToInput | (i: ScrollToInputInfo) => void | — | Called after an automatic scroll. Useful for debugging. |
| extraScrollHeight | number | — | Deprecated alias of extraSpace, for migrating from react-native-keyboard-aware-scroll-view. |
<KeyboardAvoider>
For layouts without a scroll view. Accepts every View prop, plus:
| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| enabled | boolean | true | When off, renders a plain View. |
| behavior | 'padding' \| 'translate' | 'padding' | 'padding' adds bottom padding; 'translate' shifts the container. The same default is correct on both platforms. |
| extraSpace | number | 0 | Extra room above the keyboard. |
| consumedOffset | number | 0 | Points already excluded by an ancestor, such as a bottom tab bar. Subtracted so space is not counted twice. |
| bottomInset | number | auto | Override the resolved safe-area inset. |
Unlike React Native's KeyboardAvoidingView, this measures how much of
itself the keyboard covers. A view that already ends above the keyboard is
left alone, which is why there is no keyboardVerticalOffset.
<KeyboardAwareLayout>
A flex container that lets a KeyboardAwareFooter and the scrollable beside it
agree on how much space the footer takes. Accepts every View prop, and adds
flex: 1.
It exists because the footer lifts on a transform — drawn over the scroll view's bottom edge without shortening it. Inside a layout the footer reports its height, the scrollable keeps that strip clear when it scrolls a field into view, and its content padding grows to match so the last field stays reachable.
Everything works without it; the two components simply cannot see each other.
Use bottomObstruction instead when the thing in the way is not a
KeyboardAwareFooter — an absolutely positioned bar, or a tab bar.
<KeyboardAwareFooter>
A bottom bar that rides above the keyboard. Accepts every View prop, plus:
| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| enabled | boolean | true | When off, renders a plain View. |
| extraSpace | number | 0 | Extra room above the keyboard. |
| applySafeAreaPadding | boolean | true | Keep the bottom inset as padding while the keyboard is hidden, clearing the home indicator or gesture bar. |
| bottomInset | number | auto | Override the resolved safe-area inset. |
useKeyboard()
const {
isKeyboardVisible,
keyboardHeight,
occludedHeight,
keyboardAnimationDuration,
keyboardAnimationEasing,
} = useKeyboard();| Field | Meaning |
| ----- | ------- |
| isKeyboardVisible | Whether the software keyboard is shown. |
| keyboardHeight | Height of the keyboard alone, excluding system bars. Consistent across platforms. |
| occludedHeight | Points of the window covered from the bottom edge, including any navigation bar the keyboard draws over. This is the number to use for layout maths. |
| keyboardAnimationDuration | Milliseconds. Defaults to 250 on Android, which reports none. |
| keyboardAnimationEasing | The reported curve, or 'keyboard' on Android. |
isVisible, height, duration and easing are also available under their
short names.
Every consumer shares one pair of native listeners, so calling this from many components is cheap.
useKeyboardAwareInput()
Opt-in focus registration, for projects using React Native's strict API where
the internal TextInputState module is unreachable.
const email = useKeyboardAwareInput<TextInput>();
<TextInput {...email} placeholder="Email" />;If you need your own onFocus, call the hook's version from it:
<TextInput
{...email}
onFocus={(event) => {
email.onFocus();
myHandler(event);
}}
/>Platform setup
Android
No configuration is required. The library reads the IME inset directly, so it works whether or not the window resizes.
For the best results, leave windowSoftInputMode at its default:
<!-- android/app/src/main/AndroidManifest.xml -->
<activity
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize">Notes on modern Android:
- Edge-to-edge is mandatory from Android 15 (API 35).
adjustResizeno longer shrinks your window; the system hands you insets instead. This library is built for that world — it does not depend on the window resizing. - Install
react-native-safe-area-context. Under edge-to-edge it is the only way to learn the navigation-bar inset. See installation. - API 30+ (Android 11+) uses React Native's
WindowInsetsCompatpath, which is accurate. API 29 and below falls back to React Native's legacy visible-frame heuristic, which is less reliable under edge-to-edge. adjustPanandadjustNothingwill not crash anything, but automatic scrolling assumes the layout is inset-driven.adjustResizeis the tested configuration.
iOS
No configuration is required.
- Uses
keyboardWillShow/keyboardWillHide, so movement starts with the keyboard rather than after it. - Undocked and floating iPad keyboards are deliberately ignored, matching
UIKit's own
keyboardLayoutGuide. A floating keyboard can be dragged anywhere and is conventionally not avoided. - Hardware keyboards work: the accessory bar is reported as a short keyboard and treated normally.
- Interactive dismissal keeps reporting occlusion as the keyboard is dragged away.
How it works
Normalising the geometry
Everything starts by turning platform-specific numbers into one honest pair:
iOS occludedHeight = windowHeight − endCoordinates.screenY
Android occludedHeight = endCoordinates.height + bottomSafeAreaInset
both keyboardHeight = occludedHeight − bottomSafeAreaInsetThe scroll calculation
All arithmetic happens in window coordinates, so the status bar, notch and navigation bar never enter into it — the library only ever compares two rectangles measured the same way.
focused input
↓
measure input + container (measureInWindow, in parallel)
↓
visibleTop = container.top
visibleBottom = min(container.bottom, windowHeight − occludedHeight)
↓
hiddenBelow = input.bottom + extraSpace − visibleBottom
hiddenAbove = visibleTop − input.top
↓
already visible? → do nothing ← keeps the common case still
↓
delta = hiddenBelow > 0 ? hiddenBelow : −hiddenAbove
offset = clamp(current + delta, 0, contentHeight − containerHeight)
↓
|applied delta| < 1pt ? → do nothing ← kills sub-pixel jitter
↓
scrollToTwo details do most of the work in practice:
- Nothing moves if the input already fits. This is what stops the view twitching every time the keyboard reports a new frame.
- The offset is clamped against the content bounds, and the content is
extended by
occludedHeightwhile the keyboard is open. Without that extension the last field in a form is unreachable — there is simply nowhere left to scroll to.
An input taller than the remaining space cannot satisfy both edges, so its top is aligned and the user scrolls to the rest.
How focus is detected
React Native does not expose "which input is focused" publicly, and focus events do not bubble, so a parent scroll view cannot observe a descendant gaining focus. Three layers cover it:
- React Native's internal
TextInputStatemodule, imported defensively. This is what makes the zero-configuration case work. - Explicit registration via
useKeyboardAwareInput(), for the strict API where that module is unreachable. - If neither is available, automatic scrolling is skipped and a development warning explains why. Nothing throws.
While the keyboard is open, focus moving between inputs is detected by a
150ms identity check. No platform event covers this case: iOS emits nothing for
a focus change alone, and on Android API 30+ ReactRootView only fires when
keyboard visibility changes. The check is a single property read with no
bridge traffic and only runs while the keyboard is visible; disable it with
trackFocusChanges={false} if you register inputs yourself.
Troubleshooting
Almost always the navigation-bar inset. Install
react-native-safe-area-context and wrap your app in <SafeAreaProvider>.
Confirm it with the metrics screen in the example app, or:
const { keyboardHeight, occludedHeight } = useKeyboard();
// occludedHeight − keyboardHeight should equal your bottom inset.
// If it is 0 on a gesture-navigation device, the inset is not being resolved.As a stopgap, pass the inset explicitly: <KeyboardAwareScrollView bottomInset={48} />.
Check you have not set applyKeyboardPadding={false}, and that nothing
downstream overrides contentContainerStyle's paddingBottom with a fixed
value.
Under edge-to-edge it is not supposed to — the system stopped resizing windows
in Android 15 and hands you insets instead. This library does not need the
resize. If layout elsewhere in your app depends on it, that code needs
migrating to insets; useKeyboard().occludedHeight gives you the number.
Usually a competing keyboard handler. Remove any KeyboardAvoidingView,
keyboard-aware-scroll-view or keyboard-controller wrapper from the same
screen — two libraries adjusting the same layout will fight.
Also check you are not nesting KeyboardAvoider around a
KeyboardAwareScrollView; the scroll view handles it alone.
Focus detection may be unavailable — you will see a development warning saying
so. This happens on React Native's strict API. Attach
useKeyboardAwareInput() to your inputs.
Put the KeyboardAwareScrollView inside the Modal, not around it. Opening
a modal while the keyboard is already up is supported: the library seeds its
state from Keyboard.isVisible() when it first subscribes.
Third-party bottom sheets are not tested and not claimed.
That is React Native's default keyboardShouldPersistTaps="never". This
library defaults it to 'handled'; if you set it back to 'never', the first
tap only dismisses the keyboard.
Known limitations
Stated plainly, because finding these out later is worse:
- No per-frame keyboard-synced animation on Android. Requires
WindowInsetsAnimationCallback, which JavaScript cannot reach. Movement is animated with a matched duration instead. Usereact-native-keyboard-controllerif this matters. - Android keyboard height changes are undetectable on API 30+. React
Native's
ReactRootViewonly emits on visibility changes, so switching to the emoji keyboard or a different IME while typing produces no event. iOS handles this correctly viakeyboardWillChangeFrame. - Android API 29 and below relies on React Native's legacy visible-frame heuristic, which is unreliable under edge-to-edge.
- No interactive-dismissal tracking on Android. iOS reports it; Android has no equivalent event.
invertedFlatLists keep working but do not auto-scroll.- Third-party bottom sheets are not supported — not because they cannot work, but because they have not been tested, and claiming otherwise would be dishonest.
Contributing
npm install
npm run lint
npm run typecheck
npm test
npm run buildThe geometry is pure and heavily unit-tested — src/core/normalize.ts and
src/core/solver.ts are the two files worth reading first. Behaviour changes
there should come with a failing test.
There is a ten-screen example app covering every keyboard situation the library handles. A device pass through it is expected before any release.
Links
- Example app — ten screens, one per keyboard scenario
- Changelog
- Report an issue
License
MIT © Shubham Maske
