Gamepad Support with the Gamepad API

Updated June 2026
The Gamepad API lets web games read input from USB and Bluetooth game controllers directly in the browser. It works with Xbox, PlayStation, Switch Pro, and most generic HID gamepads across Chrome, Firefox, Safari, and Edge. Unlike keyboard and mouse events, gamepad input uses a polling model where you read the controller state on every animation frame.

Controller support transforms a web game from a desktop-only experience into something that works on a couch with a TV, a Steam Deck running a browser, or any setup where the player prefers a gamepad over keyboard and mouse. The Gamepad API has been stable in all major browsers since 2015, and the implementation differences between browsers have largely converged. Adding gamepad support is straightforward once you understand the polling model and the quirks of analog input.

Listen for Controller Connections

The browser fires a gamepadconnected event on the window object when a controller is plugged in or first interacts with the page. The event object has a gamepad property containing the Gamepad object with its id string, index in the gamepads array, and initial button and axis states. A corresponding gamepaddisconnected event fires when the controller is removed.

The gamepad.id string identifies the controller model, but the format varies by browser and operating system. Chrome on Windows might report "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 02fd)" while Firefox reports "045e-02fd-Xbox Wireless Controller." If you want to display a friendly controller name or show the correct button icons (Xbox A vs PlayStation Cross), you need to match against known substrings or vendor/product ID pairs. Maintaining a small lookup table with entries for common controller identifiers covers the majority of players.

Store connected gamepad indices in your input system. When a gamepad connects, record its index. When it disconnects, remove it. Some games support multiple local players, each with their own controller, so your system should handle an array of active gamepad indices rather than assuming a single controller.

One important caveat: some browsers do not fire the gamepadconnected event until the user presses a button on the controller for the first time after the page loads. This is a privacy measure to prevent fingerprinting. Your game should prompt the player to "press any button to connect your controller" if no gamepad is detected on load.

Poll Button and Axis State Each Frame

The Gamepad API has no events for button presses, stick movements, or trigger pulls. Instead, the game calls navigator.getGamepads() on every requestAnimationFrame tick and reads the current state from the returned array. This polling model means you must actively check every frame, unlike keyboard and mouse input where the browser pushes events to your handlers.

The getGamepads() method returns an array where each index corresponds to a connected gamepad. Disconnected slots are null. Each Gamepad object has a buttons array and an axes array. The buttons array contains GamepadButton objects, each with a pressed boolean (true if the button is held) and a value float from 0.0 to 1.0. The value matters for analog triggers: a trigger pulled halfway reports pressed: true and value: 0.5. Face buttons report either 0.0 or 1.0 since they are digital.

The axes array contains float values from -1.0 to 1.0. On a standard-mapping controller, axes[0] is the left stick horizontal (negative left, positive right), axes[1] is the left stick vertical (negative up, positive down), axes[2] is the right stick horizontal, and axes[3] is the right stick vertical. The vertical axes follow screen coordinate convention where negative is up, which may be the opposite of what your game's coordinate system expects.

Call getGamepads() once per frame and store the result. Do not call it multiple times in the same frame, since the data does not change between calls within a single animation frame. Reading the data at the very start of your game loop, before updating game logic, gives the freshest possible input for that frame.

Apply Radial Dead Zones to Thumbsticks

Physical thumbsticks have manufacturing tolerances that cause them to report small non-zero values even when the player is not touching them. A brand-new Xbox controller might report axes values of 0.01 to 0.03 at rest, and a worn controller can drift as high as 0.10 or more. Without a dead zone, the player's character will creep slowly in a random direction when the sticks are untouched.

Apply dead zones radially rather than per-axis. A per-axis dead zone creates a square-shaped dead region at the center, which means diagonal input near the dead zone boundary behaves inconsistently. A radial dead zone creates a circular dead region, which matches the physical geometry of the stick and feels natural.

To apply a radial dead zone: take the X and Y axis values for one stick, compute the magnitude (distance from center) using sqrt(x*x + y*y), and compare it to your dead zone threshold. If the magnitude is below the threshold, output zero. If it is above, remap the range so the dead zone boundary becomes zero and full deflection (1.0) remains 1.0. The formula is: adjustedMagnitude = (magnitude - deadZone) / (1.0 - deadZone). Then multiply the normalized direction vector by this adjusted magnitude to get the final output.

A dead zone of 0.15 works well as a default. Worn controllers may need 0.20 or higher. Offering a dead zone slider in the settings menu lets players compensate for their specific hardware. Some competitive players prefer a very small dead zone (0.05 to 0.10) for maximum responsiveness, even at the cost of occasional drift.

Handle the Standard Mapping Layout

When a Gamepad object's mapping property equals "standard," the buttons and axes follow the W3C standard layout. This is a consistent assignment that works across Xbox, PlayStation, and Switch Pro controllers, even though the button labels differ between them. The standard layout has 17 buttons and 4 axes in defined positions.

The button indices are: 0 (A / Cross), 1 (B / Circle), 2 (X / Square), 3 (Y / Triangle), 4 (left bumper / L1), 5 (right bumper / R1), 6 (left trigger / L2), 7 (right trigger / R2), 8 (select / back / share), 9 (start / options), 10 (left stick click / L3), 11 (right stick click / R3), 12 (d-pad up), 13 (d-pad down), 14 (d-pad left), 15 (d-pad right), and 16 (home / guide / PS button). The axis indices are: 0 (left stick X), 1 (left stick Y), 2 (right stick X), 3 (right stick Y).

When the mapping property is empty or absent, the controller does not follow the standard layout. Its buttons and axes may be in any order, and the only way to handle it correctly is to let the player remap controls manually. Your settings UI should include a "press the button you want to use for Jump" flow that records whatever button index the player presses, regardless of its position in the standard layout.

Define your game's bindings as an object mapping action names to button indices: { jump: 0, attack: 2, interact: 3, dash: 1 }. This makes it trivial to swap bindings for different controller conventions (Nintendo's A/B positions are reversed compared to Xbox) or to let the player customize their layout.

Track Button Press and Release Transitions

The polling model gives you the current state of every button on each frame, but many game actions need to know when a button was just pressed (this frame) versus being held. Jumping should trigger on the frame the button is first pressed, not every frame the button is held. Charging attacks should know when the button is released.

Store the previous frame's button states alongside the current frame's states. At the start of each frame, before reading the new gamepad data, copy the current states to a "previous" snapshot. Then read the new states. A button is "just pressed" if it is pressed now and was not pressed in the previous snapshot. A button is "just released" if it is not pressed now but was pressed previously. A button is "held" if it is pressed in both frames.

For analog triggers, you may also want to track the transition from below a threshold to above it. If your game uses the trigger for accelerating a vehicle, the "just pressed" transition happens when the trigger value crosses from below 0.1 to above 0.1, not when it reaches 1.0. The threshold value should match the dead zone you apply to trigger input.

This state comparison pattern is the same principle used for keyboard input ("just pressed" versus "held"), and in a unified input system, both keyboard and gamepad feed into the same action state structure with the same three states: pressed this frame, held, and released this frame.

Add Optional Haptic Feedback

Some controllers support rumble feedback through the GamepadHapticActuator interface. The Gamepad object may have a vibrationActuator property with a playEffect() method. The most common effect type is "dual-rumble," which controls two motors: a heavy low-frequency motor and a light high-frequency motor. Each motor's intensity ranges from 0.0 to 1.0, and the duration is specified in milliseconds.

A typical rumble call for a hit effect might set the strong motor to 0.5 and the weak motor to 0.3 for 150 milliseconds. An explosion could use 1.0 on the strong motor and 0.8 on the weak motor for 300 milliseconds. Subtle effects like footsteps on different terrain might pulse the weak motor at 0.1 for 50 milliseconds. The key is restraint: constant rumble becomes annoying, and players should always have the option to disable it.

Check for haptic support before calling playEffect(). Not all browsers implement it (Safari does not as of early 2026), and not all controllers have vibration motors. A safe approach is: if the gamepad has a vibrationActuator property and the property has a playEffect method, call it. Otherwise, skip silently. Never let a missing haptic feature cause an error that breaks the input loop.

The Chrome implementation also supports a "trigger-rumble" effect type on Xbox controllers with impulse triggers, which vibrates the triggers independently from the main motors. This is a premium haptic feature that very few web games use, but it can provide spatial feedback (vibrating the left trigger when something impacts the left side of the character).

Key Takeaway

Poll navigator.getGamepads() every frame, apply radial dead zones to thumbsticks, use the standard mapping indices for cross-controller compatibility, track frame-over-frame button transitions for press and release detection, and add optional rumble through vibrationActuator. This gives players a native controller experience directly in the browser.