The JavaScript Gamepad API: A Practical Guide to Reading Controller Input
DEV Community

The JavaScript Gamepad API: A Practical Guide to Reading Controller Input

The first time you wire up a game controller in a browser, the API will almost certainly appear broken. You plug in an Xbox pad, call navigator.getGamepads(), and get back an array of four null values. Nothing is wrong. The API is just quieter and stranger than most browser APIs, and almost none of that strangeness is obvious from the method signature.

This is a walkthrough of how the Gamepad API actually behaves: how polling works, what buttons and axes really contain, why dead zones exist, and why the same controller reports different data over USB than over Bluetooth. There is working code for each part, and a section at the end on the mistakes that cost me the most time.

Browser-based controller testing is worth understanding for two separate reasons. The obvious one is that you want to build something with controller input: a WebGL game, a cloud gaming client, an accessibility control scheme, a kiosk. The less obvious one is diagnostics. A web page is the fastest way to inspect a piece of USB or Bluetooth hardware, because there is no installer, no driver, no signed binary, and no platform-specific build. You open a URL and the raw input is on screen. That property makes the browser a genuinely good place to answer questions like "is this stick drifting or is my game misconfigured?"

The short version

The Gamepad API is poll-based, not event-based, for everything except connect and disconnect. Browsers hide gamepads until the user presses a button, which is a deliberate fingerprinting defence. Chromium requires a secure context, so http:// pages other than localhost get nothing. Buttons are objects with a pressed boolean and an analog value, not plain booleans. Axes are floats from -1 to 1 that are almost never exactly 0, which is what dead zones exist to absorb.

What is the JavaScript Gamepad API?

The Gamepad API is a small browser interface that exposes the state of connected game controllers as read-only snapshots. It does not fire events when a button is pressed. Instead, you ask the browser for the current state of every pad, and you ask again on the next frame. Two events exist, gamepadconnected and gamepaddisconnected, and that is the entire event surface.

Why it polls instead of firing input events

This design surprises people, but it matches how the hardware works. A USB HID gamepad sends a full report of every button and axis at a fixed interval. It does not send "button 3 went down." It sends "here is the state of all 17 buttons and 4 axes," over and over, whether anything changed or not. Turning that stream into DOM events would mean the browser generating hundreds of events per second per controller, most of them carrying identical data. Polling puts you in control of the sample rate instead, which matters when your render loop and the controller's report rate do not line up. The practical consequence is that you need a loop. Almost always that loop is requestAnimationFrame, which runs roughly in step with the display refresh rate and pauses when the tab is hidden.

Buttons

gamepad.buttons is an array of GamepadButton objects, not booleans. Each one has three properties:

  • pressed: a boolean, true while the button is held.
  • value: a float from 0 to 1. Digital buttons snap between 0 and 1. Analog triggers give you the full range.
  • touched: a boolean that some controllers set when a finger rests on a button without depressing it. DualSense and DualShock 4 triggers report this. Most others just mirror pressed.

Reading if (gamepad.buttons[0]) is always true, because an object is always truthy. That single mistake is responsible for a large share of "my controller input is stuck on" bug reports.

Axes

gamepad.axes is an array of floats from -1 to 1. Under the standard mapping there are four: left stick X, left stick Y, right stick X, right stick Y, in that order. Negative Y is up, which trips people up because it is the opposite of what a lot of engines assume.

Triggers are not in axes under the standard mapping. They live in buttons[6] and buttons[7] and you read their analog position from .value. On non-standard mappings the triggers frequently do show up as axes, sometimes with a resting value of -1 instead of 0.

Browser support and the rules around it

Support is broad. Chrome, Edge, Firefox, Safari, and Chromium derivatives all implement it. Three constraints matter more than raw support:

  • Secure context. The specification itself does not mark the API as secure-context-only, but Chromium shipped a restriction that requires one. In practice that means HTTPS, or localhost during development. Serving your test page over plain HTTP from a LAN IP will silently give you nothing in Chrome and Edge, and the failure looks identical to "no controller plugged in."
  • User gesture requirement. A pad that is already connected when the page loads stays hidden until the user presses a button or moves an axis while the page is focused. Until then, getGamepads() returns nulls and gamepadconnected never fires. Firefox documents this explicitly as a fingerprinting defence, since the list of attached HID devices is a strong identifier, and Chromium behaves the same way.
  • Permissions Policy. If your page runs inside an iframe, the parent must grant access with allow="gamepad", and the top-level document must not be blocking it with a Permissions-Policy header. Embedded widgets that work perfectly on their own origin and mysteriously fail when embedded are usually hitting this.

Here is the full lifecycle:

flowchart TD
A[Page loads on a secure origin] --> B[navigator.getGamepads returns nulls]
B --> C[User presses any button on the pad]
C --> D[gamepadconnected fires on window]
D --> E[Start requestAnimationFrame loop]
E --> F[navigator.getGamepads]
F --> G[Read buttons and axes from the snapshot]
G --> H[Diff against previous frame, update state]
H --> E
D -.-> I[Cable pulled or Bluetooth drops]
I --> J[gamepaddisconnected fires]
J --> K[Cancel the loop, clear cached state]

Detecting a gamepad

Start with feature detection and the two connection events. The snippets below are illustrative rather than one runnable file: showMessage, cleanUpState, readSticks, and applyThrottle are stand-ins for your own code. Everything else is real API surface.

if (!('getGamepads' in navigator)) {
  showMessage('This browser does not support the Gamepad API.');
}

window.addEventListener('gamepadconnected', (event) => {
  const pad = event.gamepad;
  console.log(
    `Connected at index ${pad.index}: "${pad.id}", ` +
    `mapping "${pad.mapping}", ` +
    `${pad.buttons.length} buttons, ${pad.axes.length} axes`
  );
  startLoop();
});

window.addEventListener('gamepaddisconnected', (event) => {
  console.log(`Disconnected at index ${event.gamepad.index}`);
  cleanUpState(event.gamepad.index);
  stopLoop();
});

Going through it line by line:

  • 'getGamepads' in navigator is the cheapest reliable check. Do not test for the Gamepad constructor, which some environments expose without a working implementation.
  • event.gamepad is a full Gamepad snapshot, not a bare id. You get the whole object at connect time, which is useful for setting up your UI before the first poll.
  • pad.index is the slot the browser assigned. It is stable while the pad stays connected and it is your key for any per-controller state you cache. Do not use pad.id as a key, because two identical controllers produce the same id string.
  • pad.id is a free-form vendor string. Chromium usually formats it as Vendor Product (STANDARD GAMEPAD Vendor: 045e Product: 02ea). Firefox uses a different shape entirely. Parsing it for vendor and product ids works, but treat it as a heuristic rather than a contract.
  • pad.mapping is the one field worth branching on. It is "standard" if the browser recognised the device and remapped it to the canonical layout, "xr-standard" for XR controllers, and an empty string when it did not recognise the device at all. An empty mapping means button indices are whatever the hardware happened to report, and your buttons[0] is A assumption is void.

Now the loop

let rafId = null;

function startLoop() {
  if (rafId === null)
    rafId = requestAnimationFrame(poll);
}

function stopLoop() {
  if (rafId !== null) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
}

function poll() {
  const pads = Array.from(navigator.getGamepads());
  for (const pad of pads) {
    if (!pad) continue;
    readButtons(pad);
    readSticks(pad);
  }
  rafId = requestAnimationFrame(poll);
}

Two details in there are load-bearing.

  • The Array.from() wrapper exists because older engines returned a GamepadList rather than a real array, and it costs nothing.
  • The if (!pad) continue guard exists because the returned array has a fixed length with empty slots as null, so index 0 being null while index 2 holds a controller is completely normal.

The important rule: call getGamepads() fresh every frame and never hold onto the returned objects. Whether a browser hands you an immutable snapshot or an object it mutates in place has varied across engines and versions. Code that caches a Gamepad reference outside the loop reads correctly in one browser and freezes on the first frame's values in another.

Reading button input

Raw pressed booleans are fine for "is the player holding the accelerator." They are wrong for "did the player just jump," because a held button reads as pressed on every frame of the loop. You need edge detection: comparing this frame against the last one.

const previousButtons = new Map(); // pad.index -> boolean[]

function readButtons(pad) {
  const last = previousButtons.get(pad.index) ?? [];
  const current = pad.buttons.map((button) => button.pressed);

  current.forEach((isDown, i) => {
    const wasDown = last[i] === true;
    if (isDown && !wasDown) onButtonDown(pad, i);
    if (!isDown && wasDown) onButtonUp(pad, i);
  });

  previousButtons.set(pad.index, current);
}

function onButtonDown(pad, index) {
  console.log(`Pad ${pad.index}: button ${index} down`);
}

function onButtonUp(pad, index) {
  console.log(`Pad ${pad.index}: button ${index} up`);
}

previousButtons is keyed by pad.index so multiple controllers do not overwrite each other. The last[i] === true comparison rather than a truthiness check handles the first frame, where last is an empty array and every lookup is undefined.

For analog triggers, read value instead:

const rightTrigger = pad.buttons[7];
if (rightTrigger.value > 0.05) {
  applyThrottle(rightTrigger.value); // 0.0 to 1.0
}

Under the standard mapping, the indices you will use most are:

Index Button Index Button
0 Bottom face (A / Cross) 8 Select / Share / View
1 Right face (B / Circle) 9 Start / Options / Menu
2 Left face (X / Square) 10 Left stick click
3 Top face (Y / Triangle) 11 Right stick click
4 Left bumper 12 D-pad up
5 Right bumper 13 D-pad down
6 Left trigger (analog) 14 D-pad left
7 Right trigger (analog) 15 D-pad right

Index 16 is the guide button (Home, PS, Xbox button). The standard mapping defines all 17, but several platforms reserve that button for the OS and never pass it to the page, so a buttons.length of 16 instead of 17 is common and not necessarily a bug.

Reading analog sticks

Analog sticks do not rest at zero. Physical potentiometers and Hall effect sensors both have tolerance, the plastic has slack, and the ADC has noise. A stick sitting untouched on a desk typically reports something in the range of 0.01 to 0.08 on each axis, and it wanders. If you feed those raw values straight into movement, the character walks on its own. The fix is a dead zone: a region near centre where you treat the input as exactly zero.

Do not use an axial dead zone

The naive version clamps each axis independently:

// Common, and wrong.
const x = Math.abs(pad.axes[0]) < 0.15 ? 0 : pad.axes[0];
const y = Math.abs(pad.axes[1]) < 0.15 ? 0 : pad.axes[1];

This carves a square hole out of a circular input range. Push the stick to a shallow diagonal and one axis clears the threshold while the other is still suppressed, so movement snaps to the cardinal directions near centre. Players describe this as the stick feeling "sticky" or "notchy."

Measure the vector magnitude instead, then rescale the surviving range so output still starts at 0 and reaches 1:

function applyRadialDeadzone(x, y, deadzone = 0.12, ceiling = 0.95) {
  const magnitude = Math.hypot(x, y);
  if (magnitude < deadzone) {
    return { x: 0, y: 0, magnitude: 0 };
  }
  // Rescale [deadzone, ceiling] onto [0, 1].
  const clamped = Math.min(magnitude, ceiling);
  const normalised = (clamped - deadzone) / (ceiling - deadzone);
  const scale = normalised / magnitude;
  return { x: x * scale, y: y * scale, magnitude: normalised };
}

The ceiling parameter handles the opposite problem. Many sticks cannot physically reach 1.0 at the edge of their gate, so without it, players never get full speed. Clamping slightly below the theoretical maximum means the outer edge of travel reliably reads as 100 percent.

Here is where that sits in the pipeline:

flowchart LR
A[Raw axes from getGamepads] --> B[Compute vector magnitude]
B --> C{magnitude below deadzone?}
C -->|Yes| D[Output 0, 0]
C -->|No| E[Clamp to ceiling, rescale to 0..1]
E --> F[Apply response curve]
F --> G[Game or UI input]

The response curve is optional and worth knowing about. A linear stick feels twitchy for aiming, because small movements near centre produce proportionally large changes. Squaring the magnitude while preserving direction gives finer control near centre:

function applyCurve(value, exponent = 2) {
  return Math.sign(value) * Math.abs(value) ** exponent;
}
Approach Diagonal behaviour Best for
Axial (per-axis clamp) Snaps to cardinals near centre Nothing, avoid it
Radial, no rescale Correct shape, but output jumps from 0 to deadzone Quick prototypes
Radial with rescale Smooth, accurate range Production

Comments

No comments yet. Start the discussion.