I never thought I'd spend a weekend reverse-engineering controller input for a browser game. But there I was at 2am, holding a PS5 DualSense in one hand and staring at a blank navigator.getGamepads() return array, wondering why nothing was showing up.
That rabbit hole taught me more about browser APIs, hardware polling, and input latency than any tutorial I'd read before. This article is what I wish existed when I started.
Why Bother With Gamepad Support on the Web?
Before we get into code, let me make the case.
Web gaming is not a joke anymore. With WebGL, WebGPU, and WebAssembly all maturing fast, browser-based games are reaching quality levels that were unthinkable five years ago. Games like HexGL, Dino Smasher, and countless itch.io projects run entirely in the browser. And increasingly, players expect to plug in their Xbox or PS5 controller and just have it work.
If you're building anything game-adjacent — a browser game, an accessibility tool, a creative coding project, even a custom dashboard you want to navigate with a controller — the HTML5 Gamepad API is your entry point. It's well-supported across Chrome, Firefox, and Edge, it requires zero libraries, and it's genuinely fun to work with.
Let's build something real.
The Core Concept: How the Gamepad API Actually Works
The Gamepad API is event-driven at the connection level, but polling-based for reading input. That's a distinction that trips up almost everyone the first time.
Here's what that means:
You listen for gamepadconnected and gamepaddisconnected events to know when a controller is plugged in or unpaired.
But you don't get events for button presses or stick movements. You have to read the state yourself on every animation frame.
There's one more gotcha: the browser won't even "see" a controller until the user physically presses a button. This is an intentional security/privacy feature — the browser doesn't want to silently fingerprint your hardware. So you can have a controller plugged in and navigator.getGamepads() will return an empty array until that first button press.
Here's the minimal skeleton:
window.addEventListener("gamepadconnected", (event) => {
console.log("Controller connected:", event.gamepad.id);
requestAnimationFrame(readGamepad);
});
window.addEventListener("gamepaddisconnected", (event) => {
console.log("Controller disconnected:", event.gamepad.id);
});
function readGamepad() {
const gamepads = navigator.getGamepads();
for (const gamepad of gamepads) {
if (!gamepad) continue;
// Buttons
gamepad.buttons.forEach((button, index) => {
if (button.pressed) {
console.log(`Button ${index} pressed (value: ${button.value})`);
}
});
// Axes (analog sticks)
gamepad.axes.forEach((axis, index) => {
if (Math.abs(axis) > 0.1) { // deadzone filter
console.log(`Axis ${index}: ${axis.toFixed(4)}`);
}
});
}
requestAnimationFrame(readGamepad);
}
That's the whole foundation. But there's a lot of nuance hiding in those ~25 lines.
Understanding the Data Structure
When you call navigator.getGamepads(), you get back an array (up to 4 slots) of Gamepad objects — or null for empty slots. Each Gamepad looks like this:
`Gamepad {
id: "Xbox 360 Controller (XInput STANDARD GAMEPAD Vendor: 045e Product: 028e)",
index: 0,
connected: true,
timestamp: 1234567.89,
mapping: "standard",
axes: [0.0, 0.0, 0.0, 0.0], // Left stick X/Y, Right stick X/Y
buttons: [
{ pressed: false, touched: false, value: 0 }, // Button 0 (A/Cross)
// ... up to 17 buttons
]
}
The mapping Field
This is important and often ignored. When mapping is "standard", the browser has normalized the button layout to a consistent order regardless of manufacturer. That means button 0 is always the bottom face button (A on Xbox, Cross on PlayStation), button 1 is always the right face button, etc.
If mapping is "" (empty string), you're dealing with a non-standard controller and the button order is manufacturer-specific. This is common with cheap third-party pads or older peripherals.
The id String
The id is a goldmine of information. It typically contains the Vendor ID and Product ID, which you can use to identify exactly which controller is connected. For example:
"
Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)"
Vendor 054c is Sony, Product 0ce6 is the DualSense. I discovered this while debugging why my vibration code was working on one controller but not another — turns out I was checking the wrong vendor string.
Handling the Deadzone Problem
If you log raw axis values with a joystick centered, you'll rarely see exactly 0.0000. You'll see 0.0234 or -0.0087. This is normal — analog sticks have physical imprecision, and it gets worse as controllers age.
Without a deadzone filter, your character will constantly drift, your camera will slowly rotate, and your game will be unplayable.
Here's how I handle it:
javascript
function applyDeadzone(value, threshold = 0.08) {
if (Math.abs(value) < threshold) return 0;
// Rescale the remaining range so movement starts from 0, not from threshold
const sign = value > 0 ? 1 : -1;
return sign * (Math.abs(value) - threshold) / (1 - threshold);
}
The rescaling step is crucial. Without it, you get a "snap" where movement jumps from 0 to 0.08 the moment you cross the threshold. With rescaling, movement starts smoothly from 0 and still reaches full 1.0 at the stick's maximum travel.
I'd actually recommend verifying what your specific controller's raw values look like before hardcoding a threshold. A great way to see this in real time without writing any code: go to GamepadTester.pro, plug in your controller, press any button to wake it up, and watch the axis values live. You'll see exactly how much drift your stick has when it's resting. That number becomes your deadzone threshold.
Vibration: The Feature Nobody Tells You About
Rumble support exists in the Gamepad API, but it's tucked away in a way that a lot of tutorials skip entirely.
The relevant property is gamepad.vibrationActuator. Not all controllers expose this, and browser support varies, so you need to guard for it:
`javascript
function rumble(gamepad, duration = 200, strongMagnitude = 1.0, weakMagnitude = 0.5) {
if (!gamepad.vibrationActuator) {
console.warn("Vibration not supported on this controller/browser");
return;
}
gamepad.vibrationActuator.playEffect("dual-rumble", {
startDelay: 0,
duration: duration,
weakMagnitude: weakMagnitude,
strongMagnitude: strongMagnitude,
});
}
`
strongMagnitude controls the heavy low-frequency motor (usually the left one). weakMagnitude controls the light high-frequency motor (usually the right one). Both range from 0.0 to 1.0.
This API works well in Chrome and Edge. Firefox has partial support. Safari is basically a no-go for vibration. If you're building something where haptic feedback matters, that's worth communicating to your users.
For the DualSense specifically, there's a newer haptics actuator that supports the PS5's adaptive trigger feedback — though browser support for that is still experimental as of 2026.
Building a Real Input Manager
Bare requestAnimationFrame loops get messy fast. Here's a more structured approach I've landed on after a few projects:
`javascript
class GamepadManager {
constructor() {
this.gamepads = {};
this.previousButtons = {};
this.listeners = { press: {}, release: {}, axis: [] };
this._loop = this._loop.bind(this);
window.addEventListener("gamepadconnected", (e) => {
this.gamepads[e.gamepad.index] = e.gamepad;
this.previousButtons[e.gamepad.index] = e.gamepad.buttons.map(b => b.pressed);
console.log(`[GamepadManager] Connected: ${e.gamepad.id}`);
requestAnimationFrame(this._loop);
});
window.addEventListener("gamepaddisconnected", (e) => {
delete this.gamepads[e.gamepad.index];
delete this.previousButtons[e.gamepad.index];
});
}
onPress(buttonIndex, callback) {
if (!this.listeners.press[buttonIndex]) this.listeners.press[buttonIndex] = [];
this.listeners.press[buttonIndex].push(callback);
}
onRelease(buttonIndex, callback) {
if (!this.listeners.release[buttonIndex]) this.listeners.release[buttonIndex] = [];
this.listeners.release[buttonIndex].push(callback);
}
onAxis(callback) {
this.listeners.axis.push(callback);
}
_applyDeadzone(value, threshold = 0.08) {
if (Math.abs(value) < threshold) return 0;
const sign = value > 0 ? 1 : -1;
return sign * (Math.abs(value) - threshold) / (1 - threshold);
}
_loop() {
const gamepads = navigator.getGamepads();
for (const gamepad of gamepads) {
if (!gamepad) continue;
const prev = this.previousButtons[gamepad.index] || [];
gamepad.buttons.forEach((button, i) => {
const wasPressed = prev[i] || false;
const isPressed = button.pressed;
if (isPressed && !wasPressed) {
(this.listeners.press[i] || []).forEach(fn => fn(gamepad, button));
}
if (!isPressed && wasPressed) {
(this.listeners.release[i] || []).forEach(fn => fn(gamepad, button));
}
});
this.previousButtons[gamepad.index] = gamepad.buttons.map(b => b.pressed);
const axes = gamepad.axes.map(a => this._applyDeadzone(a));
this.listeners.axis.forEach(fn => fn(axes, gamepad));
}
if (Object.keys(this.gamepads).length > 0) {
requestAnimationFrame(this._loop);
}
}
}
// Usage
const gp = new GamepadManager();
gp.onPress(0, (gamepad) => console.log("A button pressed!"));
gp.onAxis((axes) => {
if (axes[0] !== 0 || axes[1] !== 0) {
movePlayer(axes[0], axes[1]); // Left stick
}
});
`
The key improvement here is tracking previous button states so you can fire discrete press and release events instead of a continuous "currently held" flag. This is the difference between triggering an action once vs. triggering it 60 times per second while the button is held.
Polling Rate and Why It Matters for Smooth Input
One thing that surprised me: the Gamepad API doesn't guarantee any specific polling rate. Browsers typically sync it to the display refresh rate via requestAnimationFrame — so 60fps gives you 60 input samples per second.
But the controller itself polls at its own rate. A standard Bluetooth controller polls at 125Hz. A wired Xbox controller can poll at 1000Hz. The mismatch means the browser might miss input between frames.
For most games this doesn't matter. But if you're building something competitive — an FPS, a fighting game — and you're seeing input feel "laggy" even with a wired connection, this is likely the culprit. The web platform doesn't yet give us access to sub-frame input samples, which is a real limitation compared to native apps.
You can read more about how connection type affects latency — the difference between wired and Bluetooth is significant enough that it affects competitive play. And if you want to see raw timestamp data from your own controller to measure this empirically, the online controller tester at GamepadTester.pro shows the live timestamp value from the Gamepad API so you can watch how fast it's updating between frames.
Handling Multiple Controllers
The navigator.getGamepads() array has 4 slots. Local multiplayer is totally doable:
`javascript
function readAllControllers() {
const gamepads = navigator.getGamepads();
gamepads.forEach((gamepad, playerIndex) => {
if (!gamepad || !gamepad.connected) return;
updatePlayer(playerIndex, gamepad);
});
requestAnimationFrame(readAllControllers);
}
`
One gotcha: when a controller disconnects and reconnects, it might come back in a different index slot. Always use the gamepad.index property as your player identifier rather than assuming slot 0 is always player 1.
Controller Detection and the "Unknown Gamepad" Problem
Not every controller is detected correctly by browsers. If you log gamepad.id for a cheap third-party pad, you might get something like:
plaintext
"Unknown Gamepad (Vendor: 0079 Product: 0006)"
Vendor 0079 is DragonRise, a manufacturer of ultra-cheap generic USB joysticks. The browser can read their inputs just fine, but without a "standard" mapping, the button order will be completely different from an Xbox pad.
If your game relies on specific button positions, you'll need to build a button remapping UI. This is more work, but it's the right approach if you want your game to feel polished regardless of what controller someone brings.
The easiest way to figure out a mystery controller's button mapping is to use a browser-based gamepad tester — press each button one at a time and note which B number lights up. That gives you the translation table you need.
Practical Tips From Painful Experience
Always gate input reading on gamepad.connected
The slot in the array persists after a disconnect event, but gamepad.connected will be false. Check it.navigator.getGamepads() returns a snapshot, not live objects
Call it inside your loop, not once at startup. The values don't update automatically on the object reference you already have.Test on Firefox too
Chrome is very forgiving with the Gamepad API. Firefox is stricter about certain things, especially vibration. I've shipped features that worked perfectly in Chrome and silently did nothing in Firefox.The PS5 DualSense has more inputs than the standard mapping exposes
The touchpad, gyroscope, and adaptive trigger tension are not accessible through the standard buttons/axes interface. They require the new haptics actuator or WebHID, which is a whole separate rabbit hole.Test your actual controller before assuming your code is broken
I've spent hours debugging input code that turned out to be a hardware issue — a worn-out trigger that was giving false readings. Before assuming your code is wrong, use a proper gamepad diagnostic tool to verify the hardware is sending clean data. It's saved me enormous amounts of debugging time.
What the Future Looks Like
The W3C Gamepad specification is still evolving. Things being actively worked on:
Better haptic actuator support — more granular control over vibration patterns
Pose and orientation — reading accelerometer/gyroscope data from controllers
Touchpad input — accessing XY coordinates from PS4/PS5 touchpads
WebHID — a lower-level API that bypasses browser-level controller mapping entirely, giving raw USB/Bluetooth HID access
WebHID in particular is interesting for developers who want to unlock hardware features the standard Gamepad API doesn't expose. It's already available in Chrome, though it requires a user permission prompt and is more complex to work with.
Wrapping Up
The HTML5 Gamepad API is one of those browser APIs that feels almost too simple on the surface — then you discover the edge cases, the deadzone math, the polling gotchas, the vibration inconsistencies across browsers. But once you've wrapped your head around it, it opens up a lot of creative possibilities for browser-based interactive experiences.
The key things to remember:
- The API is polling-based for input — use requestAnimationFrame
- Controllers need a user gesture (button press) before they appear
- Always implement deadzone filtering — raw axis values are noisy
- Check gamepad.mapping === "standard" before assuming button layout
- Test on multiple browsers, not just Chrome
If you're building something with gamepad support and want to verify your controllers are behaving correctly at the hardware level — especially before debugging input code for hours — GamepadTester.pro is genuinely the fastest way to get raw API data from any controller in your browser, no setup required. I use it as a first sanity check every time.
Happy building. Now go plug in a controller and break something.
Top comments (0)