WebView-based gamepad bridge for React Native. Polls navigator.getGamepads() in a hidden WebView and surfaces buttons, sticks, d-pad, touchpad click, and connection events to JS.
- Components:
GamepadBridge,useGamepad, andGamepadDebug. - Deadzone handling (default
0.15) with auto-clear on disconnect and live state snapshots to avoid stuck buttons. - Typed events for buttons, axes, d-pad, touchpad click, status, and a full-state snapshot.
Native gamepad support in React Native can be flaky or hard to maintain. Instead of relying on old native modules, it uses a hidden WebView to bridge the HTML5 Gamepad API (navigator.getGamepads()) directly to React Native. This ensures much better compatibility across iOS and Android since it relies on the web standard.
- Tested with: PS4, and generic Bluetooth controllers. Supports standard mapping.
- React Native
>=0.72 - React
>=18 react-native-webview>=13- Runs on iOS and Android (relies on WebView Gamepad API support).
npm install react-native-earl-gamepad
# or
yarn add react-native-earl-gamepadRender the hidden WebView once in your tree to start polling the first connected pad (navigator.getGamepads()[0]).
import { GamepadBridge } from "react-native-earl-gamepad";
export function Controls() {
return (
<GamepadBridge
enabled
onButton={(e) =>
console.log("button", e.button, e.pressed, e.value)
}
onAxis={(e) => console.log("axis", e.axis, e.value)}
onDpad={(e) => console.log("dpad", e.key, e.pressed)}
onStatus={(e) => console.log("status", e.state)}
/>
);
}Here is an example of mapping D-pad events to movement vectors
import { useState, useCallback } from "react";
import { GamepadBridge, type DpadEvent } from "react-native-earl-gamepad";
type MoveKey = keyof typeof MOVES;
const MOVES: Record<string, [number, number]> = {
up: [1, 0],
down: [-1, 0],
right: [0, 1],
left: [0, -1],
stop: [0, 0],
axis_left_x_neg: [0, -1],
axis_left_x_pos: [0, 1],
axis_left_y_pos: [1, 0],
axis_left_y_neg: [-1, 0],
// add more
}; // example only for the control logic
export function Controls() {
const [active, setActive] = useState<string | null>(null);
const handleDpad = useCallback(
(event: DpadEvent) => {
const key = event.key as MoveKey;
if (event.pressed) {
if (active !== key) {
console.log("Dpad press", key);
}
} else if (active === key) {
// do something
}
},
[active]
);
return <GamepadBridge enabled onDpad={handleDpad} axisThreshold={0.15} />;
}useGamepad keeps pressed state and axes for you. You still need to render the provided bridge element once.
import { useGamepad } from "react-native-earl-gamepad";
export function HUD() {
const { pressedButtons, axes, isPressed, bridge } = useGamepad({
enabled: true,
});
return (
<>
{bridge}
<Text>
Pressed: {Array.from(pressedButtons).join(", ") || "none"}
</Text>
<Text>
Left stick: x {axes.leftX?.toFixed(2)} / y{" "}
{axes.leftY?.toFixed(2)}
</Text>
<Text>A held? {isPressed("a") ? "yes" : "no"}</Text>
</>
);
}Drop-in component to see a controller diagram that lights up buttons, shows stick offsets, and lists state. Shows live metadata (name/vendor/product, mapping, axes/buttons count, vibration support) and includes vibration test buttons plus a loader prompt when no pad is connected.
The State panel includes:
- Per-stick plots (left/right) with axis values, crosshairs, and a dashed trace from center to the current dot.
- Touchpad click indicator (PS touchpad click is mapped to
touchpad; position is not exposed by the Gamepad API).
import { GamepadDebug } from "react-native-earl-gamepad";
export function DebugScreen() {
return <GamepadDebug axisThreshold={0.2} />;
}GameDemo.mp4
GamepadDebugDemo.mp4
# external repo
git clone https://github.com/Swif7ify/react-native-earl-gamepad-example
cd react-native-earl-gamepad-example
npm install
npx expo startenabled?: boolean— mount/unmount the hidden WebView. Defaulttrue.axisThreshold?: number— deadzone applied to axes. Default0.15.onButton?: (event: ButtonEvent) => void— fired on button press/release/value change.onAxis?: (event: AxisEvent) => void— fired when an axis changes beyond threshold.onDpad?: (event: DpadEvent) => void— convenience mapping of button indices 12–15.onStatus?: (event: StatusEvent) => void—connected/disconnectedevents.onState?: (event: StateEvent) => void— full snapshot of pressed buttons, values, and axes each poll.style?: StyleProp<ViewStyle>— override container; default is a 1×1 transparent view.
Options:
enabled?: boolean— defaults totrue. When false, state resets and axes zero out.axisThreshold?: number— deadzone for axes. Default0.15.onButton,onAxis,onDpad,onStatus— same semantics asGamepadBridge.
Return shape:
pressedButtons: Set<GamepadButtonName>— current pressed buttons.axes: Partial<Record<StickAxisName, number>>— axis values with deadzone applied.buttonValues: Partial<Record<GamepadButtonName, number>>— last analog value per button (useful for LT/RT triggers).isPressed(key: GamepadButtonName): boolean— helper to check a single button.bridge: JSX.Element | null— render once to enable polling.info: GamepadInfo— metadata for the first controller (id, vendor/product if exposed, mapping, counts, vibration support, timestamp, index).vibrate(duration?: number, strength?: number): void— fire a short rumble whenvibrationActuatoris available.stopVibration(): void— stop an in-flight vibration when supported.
enabled?: boolean— defaults totrue.axisThreshold?: number— defaults to0.15.
ButtonEvent:{ type: 'button'; button: GamepadButtonName; index: number; pressed: boolean; value: number }AxisEvent:{ type: 'axis'; axis: StickAxisName; index: number; value: number }DpadEvent:{ type: 'dpad'; key: 'up' | 'down' | 'left' | 'right'; pressed: boolean }StatusEvent:{ type: 'status'; state: 'connected' | 'disconnected' }InfoEvent: controller metadata payload (name/vendor/product, mapping, counts, vibration capability, timestamp, index, etc.)StateEvent:{ type: 'state'; pressed: GamepadButtonName[]; values: Record<GamepadButtonName, number>; axes: Record<StickAxisName, number> }
Button names map to the standard gamepad layout (a, b, x, y, lb, rb, lt, rt, back, start, ls, rs, dpadUp, dpadDown, dpadLeft, dpadRight, home). Unknown indices fall back to button-N. Axes map to leftX, leftY, rightX, rightY with fallbacks axis-N.
- Reads only the first controller (
navigator.getGamepads()[0]). - D-pad events mirror buttons 12–15; they emit separate
dpadmessages in addition to the raw button events. - On disconnect, pressed state is cleared and release events are emitted so you do not get stuck buttons.
- Keep the bridge mounted; remounting clears internal state and can drop transient events.
- Axis values below the deadzone are coerced to
0. AdjustaxisThresholdif you need more sensitivity. - LT/RT expose analog values via
buttonValues.ltandbuttonValues.rt.
- For movement/game loops in your app, prefer
requestAnimationFrameoversetIntervalto avoid jitter from timer drift. - Skip game loop work when no controller is connected (use
onStatusor the hook’sinfo.connected). - If you need to lower CPU/GPU cost, you can poll at a fixed interval inside your app logic (e.g., 45–60 fps) while the bridge keeps its internal rAF poll for accurate state.
- Avoid remounting the bridge; mount once near the root and let
enabledtoggle collection if you must pause.
- Single place to render: put the bridge near the root (e.g., inside your
Appprovider layer) and consume state anywhere viauseGamepad. - Status-aware UI: use
onStatusto disable controls untilconnectedand to reset UI ondisconnected. - Custom deadzone per screen: pass
axisThresholdto either the bridge or the hook depending on which you render.
npm install
npm run buildBuild outputs to dist/ with type declarations.
- [Invariant Violation: Tried to register two views with the same name RNCWebView]: Check your
package.jsonfor multiple instances ofreact-native-webviewand uninstall any duplicates. When you installreact-native-earl-gamepad,react-native-webviewis already included, so you should not install it separately. or you can check it by runningnpm ls react-native-webview. - Gamepad not focusing on the app: Ensure that your styles do not hide the
GamepadBridge(e.g.,display: 'none'), as this may prevent the WebView from receiving gamepad input. You can useopacity: 0orwidth: 0, height: 0instead.
MIT
If this project helps you, consider sponsoring its development:


