react-native-native-keyboard
v0.1.1
Published
Native hardware keyboard events for React Native 0.73.6 and newer
Maintainers
Readme
react-native-native-keyboard
Generic, native hardware keyboard input for React Native >=0.73.6 (iOS + Android). This is a bare React Native package with automatic native linking. Expo is not a dependency; native Expo development builds may consume it as a normal RN library (Expo Go cannot load custom native modules).
The library reports physical key codes and native key changes, not a text-editing API and not a synthesizer-specific MIDI mapper. It does not use an invisible first-responder view, consume key input, or swizzle UIWindow.sendEvent.
Install
pnpm add react-native-native-keyboard
cd ios && pod installRequires rebuilding the app. Both normal and isolated pnpm installs are supported (including `node-linker=isolated`): Android Gradle resolves `react-native` and its matching `@react-native/codegen` from the consuming app's dependency tree, not from the library's development checkout. No `node-linker=hoisted` workaround or manual `@react-native/codegen` dependency is required for this library.
On iOS, the capture implementation requires iOS 14+ GameController GCKeyboard; the CocoaPods deployment floor stays at 13.4 for compatibility with RN 0.73.6.
Xcode Node resolution
The example project's two Bundle React Native code and images phases
use example/ios/scripts/bundle-react-native.sh. They do not assume
node is in the restricted PATH of Xcode.app launched from the GUI.
The resolver first honors a valid NODE_BINARY from the standard
.xcode.env / .xcode.env.local files, then checks installed
NVM versions and other supported Node managers, rejecting stale paths
and Node versions older than 18. It never pins a developer's username
or a particular Node version.
If Xcode reports that Node is missing, run
example/ios/scripts/resolve-node.sh from the repository root to see
the selected executable. You do not need to edit the example's Xcode
Build Phases manually.
Declarative Android capture
For reliable physical-keyboard letters even while a TextInput inside your app
is focused, wrap its content in the native KeyboardCaptureProvider. No
MainActivity changes, root-view replacement, Accessibility permission,
overlay or injected sibling view are needed. The provider is an ordinary
focus-aware parent in your own React Native tree.
import {KeyboardCaptureProvider} from 'react-native-native-keyboard';
export default function App() {
return (
<KeyboardCaptureProvider>
<NavigationContainer>
<AppScreens />
</NavigationContainer>
</KeyboardCaptureProvider>
);
}The provider has flex: 1 by default and accepts an optional style.
It never consumes keyboard events, intercepts touches, or makes itself
clickable. It can hold ordinary Android keyboard focus while no descendant
has it (enabled by default) and yields to focused TextInputs and buttons.
No invisible sibling, hierarchy reparenting or Accessibility permission.
Like any extra native parent, it participates in layout, so test custom absolute positioning and responder interactions if
your application relies on them. A focused descendant sends the native event
through this parent before the IME. The existing transparent Activity
Window.Callback remains a best-effort post-IME fallback whenever no
provider is on the focused view's ancestor path.
All existing TextInputs remain mounted and editable. Raw key observation does not decide where text goes: when a user focuses an input, typing still edits that field. To intentionally transfer keyboard focus back to the capture surface (for example when pressing Start in a game), increment the optional focusRequestId prop:
function KeyboardPlayScreen() {
const [focusRequestId, setFocusRequestId] = React.useState(0);
return (
<KeyboardCaptureProvider focusRequestId={focusRequestId}>
<Button title="Start / return to game input"
onPress={() => setFocusRequestId(id => id + 1)} />
<GameAndNormalTextInputs />
</KeyboardCaptureProvider>
);
}Set claimFocusWhenEmpty={false} if the hosting app manages focus itself. When enabled (the default), the provider is a normal focus fallback, not a focus trap; child TextInputs and buttons still receive focus normally.
React Native Modal renders in a separate Android Dialog window, so
wrap modal content with another declarative provider:
<Modal visible={visible} onRequestClose={close}>
<KeyboardCaptureProvider>
<ModalContent />
</KeyboardCaptureProvider>
</Modal>The same applies to other independently hosted native windows. Without a provider on the focused view's ancestor path, Android's IME may swallow normal letters before the Activity/window fallback sees them. System-reserved shortcuts and keys delivered outside this app are not guaranteed.
On iOS the provider renders its children without an extra native view; GameController handles ordinary physical keyboard capture independently.
Public API
import { NativeKeyboard } from 'react-native-native-keyboard';
const first = NativeKeyboard.addListener(event => {
if (event.type === 'down' && !event.repeat) {
console.log('press', event.code, event.deviceId);
} else if (event.type === 'up' || event.type === 'cancel') {
console.log('release', event.code);
}
});
const independent = NativeKeyboard.addListener(event => {
console.log('second listener', event.nativeCode);
});
const devices = NativeKeyboard.addConnectionListener(event => {
console.log('connected', event.connected, event.deviceId);
});
// No singleton hook. Removing one listener leaves others alive.
first.remove();
console.log(await NativeKeyboard.isConnected());
console.log(await NativeKeyboard.getPressedKeys());
NativeKeyboard.releaseAll();
independent.remove();
devices.remove();Keyboard event:
interface HardwareKeyEvent {
type: 'down' | 'up' | 'cancel';
code: string; // DOM-style physical key code, or 'Unidentified'
nativeCode: number; // original platform key identifier
repeat: boolean; // another DOWN while held
timestampMs: number; // monotonic native timestamp, not UNIX time
nativeEmittedAtMs?: number; // optional native emission timestamp
modifiers: {shift: boolean; ctrl: boolean; alt: boolean; meta: boolean};
scanCode?: number; // Android only
deviceId?: number; // Android only, transient
capturePhase?: 'pre-ime' | 'window' | 'cancellation'; // Android diagnostic
}The native pressed snapshot is independently queryable. The library sends explicit cancellation for observed held keys on focus loss, app suspension, disconnect or releaseAll. On Android, cancellation logically ends a held key even if the physical switch is still down: remaining OS repeat events and the eventual key-up from that same physical hold are ignored. A genuine new press after release is accepted normally, including when the old up event was swallowed by a focus change. For musical instruments, treat cancel like note-up to avoid stuck notes.
Native architecture
iOS: GameController GCKeyboard.coalescedKeyboard and its keyChangedHandler. Normal UIKit text input retains focus. The underlying API coalesces multiple physical keyboards; per-keyboard device IDs are unavailable. On disconnect, the library conservatively cancels all held keys. It chains a previously installed GameController key handler and restores that previous handler if no other native code replaced ours in the meantime.
Android: A declarative native ViewGroup observes
dispatchKeyEventPreIme() on its focused descendant's dispatch path,
before the Android IME. The normal Window.Callback.dispatchKeyEvent()
observer remains a post-IME fallback. A bounded event fingerprint cache
deduplicates events observed in both stages. The provider is non-clickable,
does not intercept touches, and does not alter Activity/root initialization.
It can own normal keyboard focus only when no child has it, while remaining
an ancestor of focused TextInputs and buttons. InputManager observes
keyboard attachment changes. Held keys are keyed by device ID and physical key code and released
on lifecycle changes. The native module and provider communicate through a
weak, React-context-scoped registry.
The normalized physical-key mapper intentionally emits 'Unidentified' for unknown codes rather than falsely assuming platform-specific numbers correspond. Native codes are preserved. Physical code does not encode typed characters or keyboard layout.
Real platform limits
Hardware key rollover and OS-reserved key combinations can prevent the OS from delivering a complete event stream. No user-space library can reconstruct events it never sees. Native-to-JS event delivery also has scheduling jitter; instrument the actual keyboard-to-synth latency before deciding whether a separate native audio input sink is warranted.
GCKeyboard supports iOS14+, but actual keyboard and TextInput interactions still require validation on physical iPhones. On Android, an app without a provider has best-effort post-IME key capture and may miss printable keys when a TextInput/IME is focused. A provider only covers focused descendants in its own Android window, including TextInputs; React Native Modal needs its own provider. If a third party replaces the Activity Window.Callback without forwarding, the fallback may miss events. A foreground Activity is required. Android accessibility key filtering is not installed or requested.
Validation
pnpm install
pnpm typescript
pnpm test
pnpm android:rn073 # real RN 0.73.6 example: legacy + New Architecture
pnpm android:modern # isolated RN 0.83 New Architecture build
pnpm --dir example androidThe example is pinned to RN 0.73.6. It now provides a 16-step guided QA session (13 core tests + 3 optional tests), per-step expectations and manual verdicts, live pressed-key reconstruction, focus/modal tests, app-background recovery, anomaly timestamps, persistent checkpoints, and one-file JSON report export via the device share sheet.
The npm package includes a device-testing guide at `docs/QA_TESTING.md` and an optional AI-assistant integration skill at `skills/react-native-native-keyboard/SKILL.md`. The example app, JSON report exporter and its local test configuration are not shipped with the npm package. The library itself does not record or upload a key log.
Real chord, rollover, focus and performance behavior must be checked with a physical keyboard. Compilation alone cannot certify device behavior.
License
MIT.
Example-only performance profiling
The bundled QA example can estimate native callback-to-JS delivery times on
real hardware. It uses clock samples from its own native
KeyboardQAStorage.getNativeUptimeMs() module and a bounded timing monitor,
without involving the production library in React screen updates.
The cross-platform keyboard event now includes the optional
nativeEmittedAtMs monotonic timestamp, sampled shortly before the native
module emits to React Native. This shares the clock origin of the existing
timestampMs on its platform and is diagnostic only. It does not claim
hardware-switch-to-audio-output latency.
In example/QA_TESTING.md, use LIVE (250 ms) versus
OFF (capture only) to isolate the additional QA React rendering from
the native-to-JS event pipeline. No TextInputs are disabled, no keyboard
events are consumed, and all raw key records remain exportable. The test
schema remains backwards compatible; older JSON exports simply lack new
stage.performance fields.
The QA app avoids per-key React state updates, maintains only a bounded recent-event preview, defers full analysis until a step finishes, and reduces full-report checkpoint writes during heavy event streams while still saving on completion and background. This is an example UI optimization rather than a semantic change to the keyboard-capture API.
