The browser exposes gamepad connection events, but not a separate event for every button transition. Read the current gamepad snapshot in the animation loop and compare it with the previous snapshot to distinguish a new press from a held button.
Last updated: October 14, 2026.
const previous = new Map();
function pollGamepads() {
for (const gamepad of navigator.getGamepads()) {
if (!gamepad) continue;
const oldButtons = previous.get(gamepad.index) ?? [];
const newButtons = gamepad.buttons.map(button => button.pressed);
newButtons.forEach((pressed, index) => {
if (pressed && !oldButtons[index]) {
console.log('New press:', gamepad.index, index);
}
});
previous.set(gamepad.index, newButtons);
}
requestAnimationFrame(pollGamepads);
}
requestAnimationFrame(pollGamepads);Store state separately for each gamepad.index. The transition from false to true is the button-down edge; a button that remains true is being held.
Use connection events only for device lifecycle
MDN’s Gamepad API guide uses gamepadconnected and gamepaddisconnected to track devices, then reads button state from navigator.getGamepads() in a loop. Clear the saved snapshot when a controller disconnects so a later device does not inherit stale state.
Do not assume that button zero has the same physical label on every controller. Check gamepad.mapping, offer a remapping screen for nonstandard devices, and use the analog value when pressure matters.
Understand the polling limit
Polling cannot recover a complete press-and-release that happens between two snapshots. A long main-thread freeze can therefore hide very short input. Keep frame work small, move expensive computation away from the main thread when practical, and design important actions so one missed rapid tap is not destructive.
For menu navigation, edge detection prevents a held button from firing repeatedly. For movement or acceleration, read the held state every frame instead. Continue with debouncing input events, accessible keyboard handling, and starting JavaScript after DOM readiness.