Gamepad Api
The Gamepad API is green tier and works with any USB or Bluetooth controller. But it does not push events -- you poll button state 60 times per second. Here is the loop pattern and why you need previous-state tracking.
Most browser APIs are event-driven. Something happens, a callback fires. You respond.
The Gamepad API is not that.
The Gamepad API has no button-press event. You ask the browser 60 times per second "is this button pressed right now?" and check the boolean yourself. Understanding this upfront saves a lot of confusion the first time you use it. It sits in the green tier -- every major browser supports it.
Connection and Discovery
When a gamepad is plugged in or connected via Bluetooth, a gamepadconnected event fires:
window.addEventListener('gamepadconnected', (event) => {
console.log(event.gamepad.id) // "Xbox Controller" or similar
console.log(event.gamepad.buttons.length) // number of buttons
console.log(event.gamepad.axes.length) // number of axes (typically 4)
startPollingLoop()
})
window.addEventListener('gamepaddisconnected', (event) => {
stopPollingLoop()
})The API is high-level. You do not need to know the internal specifics of the controller. The browser normalizes it into buttons and axes.
No permission dialog appears. The gamepad API is in the harmless tier -- accessing a gamepad is treated like accessing a keyboard. If the OS can see it, the browser can use it.
Two Gotchas Before You Start
navigator.getGamepads() never returns an empty array. It returns a fixed-size array of four slots, with nulls for unoccupied slots. Even with zero gamepads connected, you get [null, null, null, null]. When a gamepad connects, it occupies the first available slot.
// Wrong assumption: checking .length
const gamepads = navigator.getGamepads()
// gamepads.length is always 4
// Correct: check if the slot is non-null
const gp = gamepads[0]
if (gp) { /* gamepad is present */ }The browser disconnects the gamepad on every page load. Even if the physical device is connected to the OS, the browser treats it as disconnected until the user presses a button. This is intentional -- passive fingerprinting. The page sees the gamepad only after user interaction triggers gamepadconnected.
A third practical issue: gamepads go into sleep mode after a few minutes of inactivity. The light turns off, the browser still thinks it is connected, but it stops sending state. Pressing any button wakes it up and resumes reporting.
The Poll Loop
Inside the loop, you call navigator.getGamepads() to get a snapshot of the current gamepad state. This snapshot reflects the state at the moment you call it.
let prevPressed = []
let animFrameId = null
function loop() {
const gamepads = navigator.getGamepads()
const gp = gamepads[0]
if (!gp) {
animFrameId = requestAnimationFrame(loop)
return
}
gp.buttons.forEach((button, index) => {
const isPressed = button.pressed
if (isPressed && !prevPressed[index]) {
onButtonDown(index) // fires exactly once per press
}
if (!isPressed && prevPressed[index]) {
onButtonUp(index) // fires exactly once per release
}
prevPressed[index] = isPressed
})
animFrameId = requestAnimationFrame(loop)
}
function startPollingLoop() {
animFrameId = requestAnimationFrame(loop)
}
function stopPollingLoop() {
if (animFrameId) cancelAnimationFrame(animFrameId)
}The prevPressed array is the key. Without it, onButtonDown fires every frame while the button is held -- that is 30 to 60 times per second. Games do not want that for most button actions. By comparing current state to previous state, you detect the transition: not-pressed → pressed (button down) and pressed → not-pressed (button up).
ExpandGamepad API: the poll loop pattern, button boolean state, axes -1 to 1, and previous-state tracking
Buttons and Axes
Buttons -- each gamepad.buttons[n] has two properties:
pressed: boolean, true while heldvalue: 0 to 1 float for analog triggers (useful for gas pedal-style controls)
Axes -- each gamepad.axes[n] is a float from -1.0 to 1.0. Most controllers have four axes, two per stick. For a standard layout:
axes[0]: left stick X (left=-1, right=1)axes[1]: left stick Y (up=-1, down=1)axes[2]: right stick Xaxes[3]: right stick Y
Sticks rarely return exactly 0 at rest -- there is always slight drift. For movement, apply a dead zone:
function applyDeadzone(value, threshold = 0.1) {
return Math.abs(value) > threshold ? value : 0
}Browser Support
Green tier -- the Gamepad API works in Chrome, Firefox, Safari, and Edge. Both USB and Bluetooth controllers work if the OS recognizes them using the standard gamepad interface.
Any modern console controller works: Xbox, PlayStation DualShock/DualSense, Switch Pro Controller when connected via USB or Bluetooth. The button layout varies, but the buttons and axes arrays normalize them to a standard mapping.
The Gamepad API covers the standard feature set every controller shares. But some devices have capabilities that go beyond buttons and axes -- LED color, rumble motors, built-in accelerometers. For those, there is a lower-level API called WebHID that gives you full protocol access to any HID device.
The Essentials
- No button-press event exists. Use
requestAnimationFrameto poll state 60× per second. - Compare to previous frame state to detect transitions. Without it, held buttons fire your handler dozens of times.
buttons[n].pressedis the boolean.buttons[n].valueis the 0–1 float for analog triggers.axes[0..3]are -1 to 1 floats. Apply a dead zone (threshold ~0.1) to ignore stick drift.- Green tier -- works in all browsers. USB and Bluetooth both work.
Further Reading and Watching
- Build a Gamepad Tester in 12 Minutes (YouTube) -- Coding With Adam builds a visual button display that shows every axis and button in real time
- Using the Gamepad API -- MDN -- reference guide with the full button and axes layout for standard gamepad mapping
Keep reading