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.

ModeForHow it moves
lift (default)A field in the flow of a pageMeasured 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.
dockA composer, toolbar or search bar pinned near the bottom edgeNothing 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-controller

On 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

OptionTypeDefaultDescription
enabledbooleantrueSet false to leave the element where it is.
activebooleantrueWhether this is the element that should move.
mode'lift' | 'dock''lift'Move by the overlap and follow the scroll, or travel with the keyboard.
offsetnumber16Gap kept between the element's bottom edge and the keyboard. lift only.
bottomInsetnumber0How far above the bottom edge the element already sits. dock only.

Returns

ValueTypeDescription
refAnimatedRef<View>Attach to the element that should stay visible.
onLayout(event) => voidAttach to the same element, so a re-layout at rest cannot leave it offset.
animatedStyleAnimatedStyleApply 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.

On this page