useKeyboardAvoidance
Keep an element clear of the software keyboard.
Measures an element and translates it by exactly the amount the keyboard overlaps it — and not at all when there is no overlap.
This is the difference from KeyboardAvoidingView, which shifts or pads an
entire subtree by the full keyboard height regardless of where the element
sits. A field near the top of the screen gets pushed off it; a field at the
bottom of a long form still ends up half covered.
The translation runs on the UI thread, so the element tracks the keyboard's own interpolation frame for frame and nothing re-renders while it moves.
Two modes
"Get out of the keyboard's way" and "ride the keyboard" are different jobs, and
mode picks between them.
| Mode | For | How it moves |
|---|---|---|
lift (default) | A field in the flow of a page | Measured every frame while the keyboard is up, and moved by the current overlap. Scroll it clear and the lift decays to nothing; scroll it back under and the lift returns. |
dock | A composer, toolbar or search bar pinned near the bottom edge | Nothing is measured. The element travels with the keyboard, less the bottomInset it already sits above. |
// In the flow of a scrolling form.
<KeyboardAvoider active={focused}>
<Input label="Comment" />
</KeyboardAvoider>
// Pinned to the bottom edge.
<KeyboardAvoider
mode="dock"
bottomInset={insets.bottom}
className="absolute left-0 right-0 px-5"
style={{ bottom: insets.bottom + 16 }}
>
<Composer />
</KeyboardAvoider>Install the keyboard controller
npx expo install react-native-keyboard-controllerOn Android this is close to required. Without it the hook falls back to
Reanimated's useAnimatedKeyboard, which is deprecated in Reanimated 4
and, merely by being called, switches Android out of adjustResize into
manual inset handling for the whole app. That is a global side effect from
a local hook, and it is the usual reason keyboard avoidance works on iOS and
breaks on Android.
PanelUIProvider mounts the controller's KeyboardProvider for you when the
package is installed, so there is no extra setup. If you see
Couldn't find real values for KeyboardContext, something is rendering above
PanelUIProvider — move it below.
Usage
Most of the time you do not need this hook directly — Input takes an
avoidKeyboard prop, and KeyboardAvoider wraps arbitrary content:
import { Input, KeyboardAvoider } from 'panelui-native';
<Input avoidKeyboard label="Comment" placeholder="Say something…" />;
<KeyboardAvoider offset={24} className="gap-3 p-4">
<Input label="Message" />
<Button fullWidth>Send</Button>
</KeyboardAvoider>;Reach for the hook when you want to move something other than the element being measured, or when you are building your own component:
import Animated from 'react-native-reanimated';
import { useKeyboardAvoidance } from 'panelui-native';
function Composer() {
const { ref, onLayout, animatedStyle } = useKeyboardAvoidance({ offset: 12 });
return (
<Animated.View
ref={ref}
onLayout={onLayout}
style={animatedStyle}
className="flex-row items-end gap-2 border-t border-border p-3"
>
<Input containerClassName="flex-1" placeholder="Message" />
<Button size="icon" onPress={send}>
<SendIcon size={18} />
</Button>
</Animated.View>
);
}Disable it conditionally rather than calling the hook conditionally:
const { ref, onLayout, animatedStyle } = useKeyboardAvoidance({
enabled: !isModalOpen,
});Which element should move
active decides whether this element is the one that gets out of the way, and
the right answer depends on what you have wrapped.
A composer, a toolbar or a footer should ride the keyboard whatever is focused,
which is why active defaults to true.
A field wants the opposite. Leave it on the default and every avoiding field on the screen lifts the moment any one of them is tapped — and because they all aim at the same gap above the keyboard, they arrive stacked on top of each other. Pass the field's own focus state:
const [focused, setFocused] = useState(false);
const { ref, onLayout, animatedStyle } = useKeyboardAvoidance({ active: focused });
<Animated.View ref={ref} onLayout={onLayout} style={animatedStyle}>
<TextInput onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} />
</Animated.View>Input's avoidKeyboard prop already does this for you.
active is also what hands the lift over when you move straight from one
field to another. That never closes the keyboard, so there is no keyboard
event to hang the handover on: the field being left is sent back down because
it stopped being active, not because the keyboard went away.
API Reference
Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Set false to leave the element where it is. |
active | boolean | true | Whether this is the element that should move. |
mode | 'lift' | 'dock' | 'lift' | Move by the overlap and follow the scroll, or travel with the keyboard. |
offset | number | 16 | Gap kept between the element's bottom edge and the keyboard. lift only. |
bottomInset | number | 0 | How far above the bottom edge the element already sits. dock only. |
Returns
| Value | Type | Description |
|---|---|---|
ref | AnimatedRef<View> | Attach to the element that should stay visible. |
onLayout | (event) => void | Attach to the same element, so a re-layout at rest cannot leave it offset. |
animatedStyle | AnimatedStyle | Apply to the same element. |
All three go on the same element, and in lift mode the ref is the one that
matters most: the element is re-measured through it on the UI thread every
frame for as long as it is both active and the keyboard is up.
Measuring every frame rather than once is what makes the hook survive the page moving underneath it. Everything interesting that shifts an element happens after the keyboard opens — the page scrolls, a sheet settles, content above it grows — so a position captured at the moment of focus is right for exactly one frame, and the element spends the rest of the time holding an offset that belongs to where it used to be. That is the failure that reads as a field hanging out of its own slot as soon as you scroll.
The measurement includes the translation applied on the previous frame, so the hook subtracts it back out to recover the honest resting edge. Without that it would chase its own output down the screen.
Nothing runs at rest: the frame loop is switched on when the element becomes
active with the keyboard up, and off again the moment either stops being true.
A screen full of fields costs nothing until one of them is being typed into.
dock mode measures nothing at all and needs no loop.
Notes
The element must be an Animated component — Animated.View, or anything from
Animated.createAnimatedComponent. A plain View will not accept the animated
style.
Inside a ScrollView this works as it does anywhere else — the per-frame
measurement is taken in window space, so scrolling is just another thing that
moves the element. What it does not do is scroll the page for you: it moves
one element, so a field that would need the content itself to move still wants
automaticallyAdjustKeyboardInsets (iOS) or a keyboard-aware scroll container.
Why not enabled: false on every field
Input deliberately does not call this hook with enabled: false when
avoidKeyboard is unset — it renders a different container component instead.
Calling the underlying keyboard hook at all has app-wide consequences on the
fallback path, and a field that never asked to avoid the keyboard must not
impose them on every other screen. Do the same in your own components: keep the
hook behind a component boundary rather than a flag.