Web browsers support a lot of great features these days. One you might not be aware of is the Web Gamepad API, which allows you to access and respond to inputs from USB and Bluetooth gamepads, like XBox controllers or, in my case, a joystick.

Gamepads are typically described in terms of buttons and axes. Buttons can either be on or off. These would be your ABXY, LR triggers, or any other buttons that you can press.

Axes are used for your joysticks and any other controls which have continuous values. Typically they range from -1 to 1.

The gamepad API lets you detect gamepads which are connected to your computer and query the values of their buttons and axes. We can do that using the navigator.getGamepads() API, which either returns a GamepadList object, or an array of Gamepad objects. To keep things consistent, we'll convert the GamepadList into an array for this post.

const gamepads = Array.from(navigator.getGamepads());

If we look at one of the Gamepad objects, it has all the properties we might want to access. Your gamepad might look a little different, but the principle is the same.

{
"index": 0,
"id": "6a3-75c-X52 H.O.T.A.S.",
"connected": true,
"buttons": [GamepadButton, ...],
"axes": [0, 0.5, ...],
...
}

The browser gives us the unique ID of our controller, a boolean indicating if it's connected, and a list of buttons and axes. Depending on which browser you use, you might find a few other properties, like hapticActuators, which is used to control rumble motors in the controller. This isn't well supported, so we're just going to focus on the buttons and axes.

The axes are really easy to use. Each item in the list corresponds to a different axis on our controller, while the number tells us what the value of that axis is. You might notice some of your axes aren't completely steady - they might fluctuate, or might not settle on 0 when they are at rest. This is a normal behavior, and game developers handle this by not recognizing an axis until it has crossed a certain threshold.

Like the axes, we get a list of GamepadButton objects, where each item represents a button on our gamepad. GamepadButtons give us a little more information.

{ "pressed": false, "touched": false, "value": 0 }

The numerical value property gives us a number between 0 and 1, kind of like axes. This is for trigger buttons, like those you find on the Xbox controller. My controllers don't have any buttons that work like that, so we'll just focus on the pressed property. If the button is pressed, it's true; otherwise it's false.

You might have noticed that our gamepad doesn't automatically update every time we check it. We need to implement a timed loop to regularly query the gamepad to see what its current state is. We can use requestAnimationFrame to do this. Then, inside our update function, we can perform some action based on the gamepad values.

function updateGamepad() {
requestAnimationFrame(updateGamepad);
// We'll only get the first gamepad in our list.
const gamepad = navigator.getGamepads()[0];
// If our gamepad isn't connected, stop here.
if (!gamepad) return;
// Update the background color of our page using axes from our gamepad.
// You might need to update these index values to work with your gamepad.
// Have the value go from 0 to 1 instead of -1 to 1
const hue = gamepad.axes[2] / 2;
const saturation = (gamepad.axes[0] + 1) / 2;
const lightness = (gamepad.axes[1] + 1) / 2;
document.body.style.backgroundColor = `hsl(${hue * 360},${
saturation * 100
}%,${lightness * 100}%)`;
}
updateGamepad();

Now when we change the axes on our gamepad, the background of our website changes!

Buttons are a little more complicated than axes. Instead of just using the values in our code, they are much more useful if they fire events which we can listen to, like keyboard events. To implement this, we'll hang on to a snapshot of our gamepad's state. Every loop, we'll check the current state against the snapshot. If it's changed, we'll fire the appropriate events. We'll use a CustomEvent and dispatch it on the document of our page, so we can listen to these events anywhere.

let gamepad = null;
function updateGamepad() {
requestAnimationFrame(updateGamepad);
let newGamepad = navigator.getGamepads()[0];
if (!newGamepad) return;
newGamepad.buttons.forEach((button, index) => {
const oldButtonPressed = gamepad?.buttons[index].pressed;
if (button.pressed !== oldButtonPressed) {
if (button.pressed && !oldButtonPressed) {
document.dispatchEvent(
new CustomEvent("gamepadButtonDown", {
detail: { buttonIndex: index },
})
);
}
if (!button.pressed && oldButtonPressed) {
document.dispatchEvent(
new CustomEvent("gamepadButtonUp", { detail: { buttonIndex: index } })
);
}
}
});
gamepad = newGamepad;
}
updateGamepad();
document.addEventListener("gamepadButtonDown", (event) => {
console.log(`Gamepad Button ${event.detail.buttonIndex} pressed`);
});
document.addEventListener("gamepadButtonUp", (event) => {
console.log(`Gamepad Button ${event.detail.buttonIndex} released`);
});

We can use this abstraction to treat our gamepad like a keyboard and respond based on what gamepad button is pressed. And, of course, all of this can be remixed and composed to work however you need for your app.

In these examples, we're just assuming that there is a gamepad connected. If there isn't, we bail out of our loop. If we wanted our app to be more robust, we could listen for when gamepads are connected and disconnected and run our loop for all of the connected gamepads. The Web Gamepad API gives us two events which we can listen to.

const connectedGamepads = {}
window.addEventListener("gamepadconnected", function(event) {
connectedGamepads[event.gamepad.id] = event.gamepad;
}
window.addEventListener("gamepaddisconnected", function(event) {
delete connectedGamepads[event.gamepad.id]
})
function updateGamepad() {
requestAnimationFrame(updateGamepad);
let gamepads = navigator.getGamepads();
Object.values(connectedGamepads).forEach(({id}) => {
const gamepad = gamepads.find(g => g.id === id)
// Do stuff
connectedGamepads[id] = gamepad;
})
}

Now, you might be wondering what the browser support is for something so obscure. Surprisingly, every modern browser supports the basic features, so we can use this with Chrome, Edge, Firefox, or Safari. However, in my testing Firefox didn't display all of the axes for some controllers, and Safari didn't properly update the values when I used the gamepad. Chrome (and by extension Edge) had the best support of any of the browsers. This might not matter depending on how complicated your controller is. Remember, if yours isn't showing up in one of the browsers, try unplugging it and plugging it back in and then pressing the button.