React hooks for the browser Gamepad API — buttons, axes, rumble, sequences, and multiplayer out of the box.
awesome-react-gamepads is a lightweight React hook library that wraps the native browser Gamepad API. It handles the polling loop, dead zones, button hold detection, haptics, controller profiles, and custom DOM events so you can focus on building your game or UI.
Connect a gamepad and explore the controller visualizer, docs, and playable games built with the library.
useGamepads— track all connected gamepads with a full callback and event APIuseGamepad(index)— single-controller variant; use multiple instances for local multiplayeruseGamepadSequence— detect arbitrary button combos or cheat codes (Konami, fighting-game specials, etc.)- Context API —
GamepadsProvider,useGamepadsContext, andwithGamepadsHOC; one polling loop, any depth - Controller profiles —
xbox,playstation,switch,generic;buttonLabelsmaps button names for your UI - Haptics / rumble via
rumble()withduration,weakMagnitude,strongMagnitude,startDelay - Button hold / long-press detection via
onGamepadButtonHold - Dead zone presets (
"none"|"small"|"medium"|"large") or a raw number - Configurable poll rate —
requestAnimationFrame(default) or a fixedsetIntervalinterval - Konami code built-in via
onKonamiSuccess - SSR / Next.js safe — all
windowandnavigatorcalls are guarded - Ships as ESM, CommonJS, and UMD bundles with full TypeScript types
npm install awesome-react-gamepadsPeer dependencies: React 16.8 or later.
import { useGamepads } from 'awesome-react-gamepads';
function Game() {
const { gamepad, rumble } = useGamepads({
onA: () => {
jump();
rumble({ duration: 80, strongMagnitude: 0.6 });
},
});
return <p>{gamepad?.connected ? 'Controller connected' : 'No controller'}</p>;
}Tracks all connected gamepads. Polls via requestAnimationFrame by default.
import { useGamepads } from 'awesome-react-gamepads';
const { gamepad, rumble, profile, buttonLabels } = useGamepads(options);All props are optional.
| Prop | Type | Default | Description |
|---|---|---|---|
deadZone |
number | "none" | "small" | "medium" | "large" |
"medium" |
Axis values below this threshold are clamped to 0. Presets: none=0, small=0.05, medium=0.08, large=0.15. |
stickThreshold |
number |
0.75 |
Value above which directional stick callbacks (onLeftStickRight, etc.) fire. |
holdThreshold |
number (ms) |
500 |
Duration a button must be held before onGamepadButtonHold fires. |
pollRate |
number (ms) |
— | When set, uses setInterval at this interval instead of requestAnimationFrame. |
controllerProfile |
ControllerProfile |
"xbox" |
Active button-naming profile. Affects ButtonDetails.buttonName and buttonLabels. |
onConnect |
(gamepad: ReactGamepad) => void |
— | Fired when a gamepad connects. |
onDisconnect |
(gamepad: ReactGamepad) => void |
— | Fired when a gamepad disconnects. |
onUpdate |
(gamepad: ReactGamepad) => void |
— | Fired on every poll cycle where state changed. |
onGamepadButtonDown |
(button: ButtonDetails) => void |
— | Fired on any button press. |
onGamepadButtonUp |
(button: ButtonDetails) => void |
— | Fired on any button release. |
onGamepadButtonChange |
(button: ButtonDetails) => void |
— | Fired on any button state change (down or up). |
onGamepadButtonHold |
(button: ButtonDetails) => void |
— | Fired once when a button has been held longer than holdThreshold. |
onA |
(button: ButtonDetails) => void |
— | Bottom face button (index 0) pressed. |
onB |
(button: ButtonDetails) => void |
— | Right face button (index 1) pressed. |
onX |
(button: ButtonDetails) => void |
— | Left face button (index 2) pressed. |
onY |
(button: ButtonDetails) => void |
— | Top face button (index 3) pressed. |
onLB |
(button: ButtonDetails) => void |
— | Left shoulder (index 4) pressed. |
onRB |
(button: ButtonDetails) => void |
— | Right shoulder (index 5) pressed. |
onLT |
(button: ButtonDetails) => void |
— | Left trigger (index 6) pressed. |
onRT |
(button: ButtonDetails) => void |
— | Right trigger (index 7) pressed. |
onSelect |
(button: ButtonDetails) => void |
— | Back / Select button (index 8) pressed. |
onStart |
(button: ButtonDetails) => void |
— | Start / Menu button (index 9) pressed. |
onLS |
(button: ButtonDetails) => void |
— | Left stick click (index 10) pressed. |
onRS |
(button: ButtonDetails) => void |
— | Right stick click (index 11) pressed. |
onDPadUp |
(button: ButtonDetails) => void |
— | D-Pad Up (index 12) pressed. |
onDPadDown |
(button: ButtonDetails) => void |
— | D-Pad Down (index 13) pressed. |
onDPadLeft |
(button: ButtonDetails) => void |
— | D-Pad Left (index 14) pressed. |
onDPadRight |
(button: ButtonDetails) => void |
— | D-Pad Right (index 15) pressed. |
onXBoxLogo |
(button: ButtonDetails) => void |
— | Home / Guide button (index 16) pressed. |
onGamepadAxesChange |
(axes: AxesDetails) => void |
— | Fired when any axis value changes. |
onLeftStickRight |
(axes: AxesDetails) => void |
— | Left stick crosses stickThreshold rightward. |
onLeftStickLeft |
(axes: AxesDetails) => void |
— | Left stick crosses stickThreshold leftward. |
onLeftStickUp |
(axes: AxesDetails) => void |
— | Left stick crosses stickThreshold upward. |
onLeftStickDown |
(axes: AxesDetails) => void |
— | Left stick crosses stickThreshold downward. |
onRightStickRight |
(axes: AxesDetails) => void |
— | Right stick crosses stickThreshold rightward. |
onRightStickLeft |
(axes: AxesDetails) => void |
— | Right stick crosses stickThreshold leftward. |
onRightStickUp |
(axes: AxesDetails) => void |
— | Right stick crosses stickThreshold upward. |
onRightStickDown |
(axes: AxesDetails) => void |
— | Right stick crosses stickThreshold downward. |
onKonamiSuccess |
() => void |
— | Fired when the Konami code (↑↑↓↓←→←→BA) is entered. |
Per-button callbacks (onA, onB, etc.) always refer to the same physical button position regardless of the active profile — onA always fires for button index 0 (bottom face button). Use buttonLabels from the return value to display the profile-correct name in your UI.
| Field | Type | Description |
|---|---|---|
gamepad |
ReactGamepad | undefined |
Current state snapshot of the active gamepad. undefined before first connection. |
rumble |
(options: RumbleOptions) => Promise<void> |
Trigger haptic feedback. No-ops silently if unsupported. |
profile |
ControllerProfile |
The active controller profile ("xbox", "playstation", etc.). |
buttonLabels |
Record<string, string> |
Maps Xbox button names to the active profile's display names. |
Tracks a single gamepad by index. Accepts the same options as useGamepads and returns the same value. Useful for local multiplayer where each player needs an isolated hook.
import { useGamepad } from 'awesome-react-gamepads';
function Game() {
const { gamepad: p1, rumble: rumble1 } = useGamepad(0, {
onA: () => jump(1),
controllerProfile: 'xbox',
});
const { gamepad: p2, rumble: rumble2 } = useGamepad(1, {
onA: () => jump(2),
controllerProfile: 'playstation',
});
return (
<>
<p>P1: {p1?.connected ? 'ready' : 'disconnected'}</p>
<p>P2: {p2?.connected ? 'ready' : 'disconnected'}</p>
</>
);
}Detects an arbitrary button sequence and fires callback when it is matched in order. Works standalone — no useGamepads call required in the same component.
Sequence items can be button names ("A", "Cross") or raw Standard Gamepad indices (0, 1, 2…).
import { useGamepadSequence } from 'awesome-react-gamepads';
// Konami code
useGamepadSequence(
['DPadUp','DPadUp','DPadDown','DPadDown','DPadLeft','DPadRight','DPadLeft','DPadRight','B','A'],
() => activateCheats(),
);
// Fighting-game special with a 2-second input window between presses
useGamepadSequence(
['DPadDown', 'DPadRight', 'A'],
() => fireHadouken(),
{ timeout: 2000 },
);
// PlayStation button names
useGamepadSequence(
['Cross', 'Circle', 'Cross'],
() => doCombo(),
{ controllerProfile: 'playstation' },
);
// Raw indices
useGamepadSequence([0, 1, 0], () => doSomething());| Option | Type | Default | Description |
|---|---|---|---|
timeout |
number (ms) |
0 |
Maximum time allowed between consecutive inputs before progress resets. 0 means no limit. |
resetOnMiss |
boolean |
true |
Reset progress when a wrong button is pressed. |
controllerProfile |
ControllerProfile |
"xbox" |
Profile used to resolve button names in the sequence. |
| Field | Type | Description |
|---|---|---|
reset |
() => void |
Manually reset sequence progress back to the beginning. |
Mount a single GamepadsProvider at the top of your tree. All descendants can read the gamepad state with useGamepadsContext() without prop-drilling, and without starting extra polling loops.
GamepadsProvider accepts all the same props as useGamepads, including all callbacks.
import { GamepadsProvider, useGamepadsContext } from 'awesome-react-gamepads';
function App() {
return (
<GamepadsProvider controllerProfile="playstation" onA={() => jump()}>
<Game />
</GamepadsProvider>
);
}
function HUD() {
const { gamepad, buttonLabels, rumble } = useGamepadsContext();
return (
<div>
<p>Press {buttonLabels.A} to fire</p>
<button onClick={() => rumble({ duration: 200, strongMagnitude: 0.8 })}>
Rumble
</button>
</div>
);
}useGamepadsContext() throws a descriptive error when called outside a <GamepadsProvider>.
HOC for class components (or any component that cannot call hooks directly). Requires a GamepadsProvider ancestor. The injected props match UseGamepadsReturn.
import { withGamepads, WithGamepadsProps, GamepadsProvider } from 'awesome-react-gamepads';
interface OwnProps {
playerName: string;
}
class PlayerHUD extends React.Component<OwnProps & WithGamepadsProps> {
render() {
const { playerName, gamepad, buttonLabels } = this.props;
return <p>{playerName}: press {buttonLabels.A} to jump</p>;
}
}
export default withGamepads(PlayerHUD);
// In App (GamepadsProvider must be an ancestor):
// <GamepadsProvider><PlayerHUD playerName="P1" /></GamepadsProvider>Pass controllerProfile to any hook or the GamepadsProvider to switch button naming conventions. The underlying physical layout (Standard Gamepad indices) is the same across all profiles — only the names change.
| Profile | Face buttons | Shoulders | Triggers | Back / Start | Home |
|---|---|---|---|---|---|
xbox |
A, B, X, Y | LB, RB | LT, RT | Select, Start | Xbox |
playstation |
Cross, Circle, Square, Triangle | L1, R1 | L2, R2 | Share, Options | PS |
switch |
B, A, Y, X | L, R | ZL, ZR | Minus, Plus | Home |
generic |
Button0–3 | Button4–5 | Button6–7 | Button8–9 | Button16 |
The buttonLabels field on the return value maps Xbox names to the active profile's names. Use it to render the correct label in your UI without hardcoding profile-specific strings:
const { buttonLabels } = useGamepads({ controllerProfile: 'playstation' });
<p>Press {buttonLabels.A} to confirm</p> // → "Press Cross to confirm"
<p>Press {buttonLabels.LB} to sprint</p> // → "Press L1 to sprint"Per-button callbacks (onA, onB, onX, onY, etc.) are always named after the Xbox layout and map to the same physical button index on every profile. onA fires for button index 0 regardless of whether the connected controller calls it "A", "Cross", or "B".
The rumble function returned by any hook triggers haptic feedback via GamepadHapticActuator.playEffect('dual-rumble', …).
interface RumbleOptions {
duration: number; // milliseconds
weakMagnitude?: number; // 0–1, default 0.5 (high-frequency motor)
strongMagnitude?: number;// 0–1, default 0.5 (low-frequency motor)
startDelay?: number; // milliseconds, default 0
}const { rumble } = useGamepads();
// Sharp hit feedback
rumble({ duration: 100, strongMagnitude: 1.0, weakMagnitude: 0.3 });
// Gentle continuous vibration
rumble({ duration: 500, strongMagnitude: 0.2, weakMagnitude: 0.2 });
// Delayed secondary pulse
rumble({ duration: 150, strongMagnitude: 0.8, startDelay: 200 });rumble is an async function that resolves when the effect completes. It catches and silently discards any error so it is always safe to call. If the browser or controller does not support haptics, it is a no-op.
Browser support: Chrome and Edge support dual-rumble. Firefox and Safari do not expose the haptics API — rumble silently does nothing on those browsers.
The deadZone option clamps small axis values to zero, preventing stick drift from triggering callbacks.
| Preset | Value |
|---|---|
"none" |
0 |
"small" |
0.05 |
"medium" |
0.08 (default) |
"large" |
0.15 |
A raw number is also accepted for precise control:
useGamepads({ deadZone: 0.12 });Every hook also dispatches custom events on document so non-React code can react to gamepad input. All events bubble and include a detail object.
| Event | Fired when | detail shape |
|---|---|---|
gamepadupdated |
Each poll cycle where state changed | { gamepad } |
gamepadbuttondown |
Any button pressed | { gamepad: number, buttonDetails: ButtonDetails } |
gamepadbuttonup |
Any button released | { gamepad: number, buttonDetails: ButtonDetails } |
gamepadbuttonchange |
Any button state change | { gamepad: number, buttonDetails: ButtonDetails } |
axeschange |
Any axis value changes | { gamepad: number, axes: AxesDetails } |
leftStickXRight |
Left stick crosses threshold rightward | { gamepad: number, axes: AxesDetails } |
leftStickXLeft |
Left stick crosses threshold leftward | { gamepad: number, axes: AxesDetails } |
leftStickYUp |
Left stick crosses threshold upward | { gamepad: number, axes: AxesDetails } |
leftStickYDown |
Left stick crosses threshold downward | { gamepad: number, axes: AxesDetails } |
rightStickXRight |
Right stick crosses threshold rightward | { gamepad: number, axes: AxesDetails } |
rightStickXLeft |
Right stick crosses threshold leftward | { gamepad: number, axes: AxesDetails } |
rightStickYUp |
Right stick crosses threshold upward | { gamepad: number, axes: AxesDetails } |
rightStickYDown |
Right stick crosses threshold downward | { gamepad: number, axes: AxesDetails } |
useEffect(() => {
const handler = (e: CustomEvent) => console.log('button pressed', e.detail.buttonDetails);
document.addEventListener('gamepadbuttondown', handler as EventListener);
return () => document.removeEventListener('gamepadbuttondown', handler as EventListener);
}, []);All types are exported from the package root.
import type {
UseGamepadsProps,
UseGamepadsReturn,
UseGamepadSequenceOptions,
UseGamepadSequenceReturn,
ButtonDetails,
AxesDetails,
ReactGamepad,
RumbleOptions,
ControllerProfile,
WithGamepadsProps,
} from 'awesome-react-gamepads';Passed to all button callbacks.
interface ButtonDetails {
buttonIndex: number; // Standard Gamepad button index (0–16)
buttonName: string; // Profile-specific name, e.g. "A", "Cross", "B"
pressed: boolean;
touched: boolean;
value: string; // Analog value as a string (useful for triggers)
}Passed to all axes callbacks.
interface AxesDetails {
axesIndex: number; // Standard Gamepad axes index
axesName: string; // One of: LeftStickX, LeftStickY, RightStickX, RightStickY, LeftTrigger, RightTrigger
value: number; // Current value after dead zone applied
previousValue: number; // Value on the previous poll
}Valid axesName values: LeftStickX, LeftStickY, RightStickX, RightStickY, LeftTrigger, RightTrigger.
type ControllerProfile = 'xbox' | 'playstation' | 'switch' | 'generic';| Browser | Support | Notes |
|---|---|---|
| Chrome | Full | Gamepad API and haptics both supported |
| Edge | Full | Gamepad API and haptics both supported |
| Firefox | Partial | Gamepad API supported; haptics API not available — rumble is a no-op |
| Safari | Partial | Gamepad API support is limited; haptics not available — rumble is a no-op |
rumble catches all errors internally and never throws, so it is safe to call on any browser without wrapping in a try/catch.
All accesses to window, navigator, and document are guarded with typeof window !== 'undefined' checks. The hooks return immediately during server-side rendering without starting any polling loop and without throwing, making them safe in Next.js App Router and Pages Router server components or pages with SSR enabled.
MIT