The Gamepad API for Browser Games: Console-Quality Controller Support on the Web

You probably think controller support on the web is a solved problem — plug in an Xbox pad, open a browser game, and everything just works. However, the Gamepad API hands you a raw array of buttons and axes, and every detail that makes a controller feel native is left to you to build.
Deadzones, rumble, hot-plugging, and mapping across vendors are not handled for you. They are the difference between a game that feels ported and one that feels native on the web.
That gap matters more in 2026 than it did five years ago. Players now reach browser games on Steam Deck, ROG Ally, and Legion Go handhelds, and through TV browsers on Samsung Tizen and LG webOS — surfaces where there is no keyboard and mouse to fall back on.
This post walks through wiring up the Gamepad API end to end. We will cover the polling model, reading buttons and axes, deadzones, hot-plugging, rumble, and the cross-vendor mapping problem that trips up most first implementations.
Why The Gamepad API Polls Instead Of Firing Events
The first surprise for most developers is that the Gamepad API does not fire an event when you press a button. Instead, it exposes a snapshot of controller state that you have to read on every frame.
You call navigator.getGamepads(), which returns an array of up to four Gamepad objects, or null for empty slots. Each object carries a buttons array and an axes array representing the state at the moment you polled.
Only two things are event-driven: connection and disconnection. The window fires gamepadconnected and gamepaddisconnected, and everything else — buttons, triggers, sticks — you sample yourself.
The Gamepad API is poll-based, not event-driven. You read button and axis state by calling navigator.getGamepads() every frame; only gamepadconnected and gamepaddisconnected fire as real events.
This is why the natural home for gamepad input is inside your render or update loop. Reading the snapshot once per frame keeps controller input in lockstep with the rest of your simulation.
If you are already running a fixed-timestep game loop, poll the gamepad at the top of each tick. That keeps input sampling deterministic instead of tied to a variable frame rate.
Reading Buttons, Axes, And The Standard Mapping
Each entry in the buttons array is a GamepadButton object with two fields that matter: pressed and value. The pressed field is a boolean for digital buttons, while value is a float from 0 to 1 for analog inputs like triggers.
The axes array holds float values from -1 to 1. On a standard controller, axes 0 and 1 are the left stick's X and Y, and axes 2 and 3 are the right stick.
When the browser recognizes a controller, it reports a mapping of "standard" and normalizes the layout. Under standard mapping, the button indices are predictable across vendors.
| Index | Xbox | PlayStation | Typical action |
|---|---|---|---|
| 0 | A | Cross | Confirm / jump |
| 1 | B | Circle | Cancel / back |
| 2 | X | Square | Action |
| 3 | Y | Triangle | Action |
| 6 | LT | L2 | Left trigger (analog) |
| 7 | RT | R2 | Right trigger (analog) |
| 9 | Menu | Options | Pause |
Keep in mind that standard mapping is a best effort, not a guarantee. Some pads report an empty mapping string, and on those you cannot trust the indices above.
Under "standard" mapping, button 0 is A/Cross, 1 is B/Circle, axes 0 and 1 are the left stick, and 2 and 3 are the right stick. GamepadButton exposes pressed for digital input and value from 0 to 1 for analog triggers.
Deadzones — The Detail That Separates Native From Janky
Plug in a controller, touch nothing, and read the left stick. You will almost never see exactly zero, because analog sticks rest at small nonzero values and drift over their lifespan.
Without a deadzone, that resting noise leaks into your game as a character that slowly walks into a wall on its own. This is the single most common reason web games feel cheap with a controller.
The naive fix is an axial deadzone — zero out each axis below a threshold. That works, but it creates a square dead region and makes diagonal movement feel notched.
The better approach is a radial deadzone: compute the stick's magnitude from both axes, and ignore the input only when that magnitude falls below your threshold. Better still is a scaled radial deadzone, which remaps the remaining range so motion ramps smoothly from zero instead of snapping in at the threshold.
A deadzone ignores the small resting noise and drift of analog sticks. Use a scaled radial deadzone based on combined stick magnitude, not per-axis, with a threshold near 0.10 to 0.25 for smooth, drift-free motion.
Hot-Plugging And Multiple Controllers
Players connect and disconnect controllers mid-session — a handheld sleeps, a wireless pad dies, a second player drops in. Your input layer has to survive all of it without a page reload.
Listen for gamepadconnected and gamepaddisconnected on the window. Each event carries an event.gamepad object, including its index and a human-readable id string.
Keep in mind that getGamepads() always returns a fixed-length array with null in the empty slots. Iterate and skip the nulls rather than assuming the connected pad lives at index 0.
Remember that indices can be reused. If player one disconnects and a new pad connects, it may claim the freed index, so key your per-player state on a stable identity rather than the raw slot number.
Listen for gamepadconnected and gamepaddisconnected on the window, then iterate getGamepads() and skip null slots each frame. Indices get reused, so map players to a stable identity, not the raw slot number.
This is the same class of problem as keeping a player consistent across devices and sessions. If your game spans handheld and desktop, lean on your cross-platform player identity layer to decide which controller drives which player.
Rumble And Haptics With The Vibration Actuator
Force feedback is what sells an impact, and the Gamepad API exposes it through gamepad.vibrationActuator. The modern call is playEffect with the "dual-rumble" effect type.
The dual-rumble effect takes a few parameters: startDelay, duration in milliseconds, and weakMagnitude and strongMagnitude, each from 0 to 1. The strong actuator is the low-frequency motor, while the weak one is the high-frequency buzz.
For instance, a heavy hit might use a strongMagnitude of 0.8 for 200ms, while a UI tick uses a weakMagnitude of 0.2 for 40ms. Layering short and long pulses is how you build a haptic vocabulary players feel without looking.
Browser support is uneven, so treat rumble as an enhancement rather than a guarantee. Chromium-based browsers support dual-rumble well, while Firefox and Safari are partial or absent, so feature-detect vibrationActuator before you call it.
Trigger rumble with gamepad.vibrationActuator.playEffect using the dual-rumble effect and weak/strong magnitudes. Chromium supports it well; Firefox and Safari are partial, so feature-detect before calling.
Haptics land best when they are timed against audio cues. Sync your rumble pulses to the same events that drive the Web Audio API so impact, sound, and feedback all fire on the same frame.
Mapping The Real World — Xbox, PlayStation, Switch, And Generic Pads
Standard mapping covers most modern pads, but the long tail is messy. Older controllers, third-party pads, and some Switch and arcade sticks report a non-standard layout where the indices do not match the table above.
The robust pattern is an abstraction layer: never read buttons[0] directly in your gameplay code. Instead, map raw indices to named actions — confirm, cancel, jump, dash — and keep per-controller mapping tables keyed off the id string.
This indirection pays off twice. It lets you offer button remapping for accessibility, and it gives you one place to special-case a weird pad without touching gameplay logic.
Do not read raw button indices in gameplay code. Map them to named actions through an abstraction layer keyed off the gamepad id string, so non-standard pads and player remapping are handled in one place.
Latency, Polling, And The Game Loop
Gamepad input is only as responsive as your polling cadence. At 60fps you sample once every 16.6ms or so, which sets the floor on how quickly a button press can register.
Poll at the very top of your frame, before simulation, so the freshest state drives this tick rather than the next one. Pairing the poll with input buffering for browser games smooths over the inevitable one-frame gaps and makes tight inputs like dashes and combos forgiving.
Be aware that requestAnimationFrame throttles when the tab loses focus or moves to the background. Polling effectively stops, so build your pause and resume logic around focus events rather than assuming the loop always runs.
Keeping Keyboard And Controller In Sync
Supporting a controller does not mean dropping keyboard and mouse. The cleanest architecture routes every input source — gamepad, keyboard, touch — into the same action layer, so the rest of your game never knows which device produced a jump.
This also solves the changing-prompts problem on handhelds. A Steam Deck player might tap the touchscreen, then grab the sticks, and feeding both into one action map lets your UI swap button glyphs between keyboard and controller on the fly.
Designing For Handheld PCs And TV Browsers
The reason all of this matters now is where players actually are. A browser game on a Steam Deck or a smart TV has no keyboard fallback, so a broken controller path makes the game unplayable, not merely inconvenient.
There is also a privacy gate to plan around. For fingerprinting reasons, browsers do not expose connected gamepads until the user presses a button, so getGamepads() can legitimately return an empty array on load.
Design your start screen to invite that first press — "Press any button to begin" — which both satisfies the gesture requirement and confirms the controller works. After that first input, the pad appears in getGamepads() and your loop takes over.
Browsers hide gamepads until the player presses a button, so getGamepads() can return empty on load. Use a "press any button to start" screen to satisfy the gesture gate and confirm the controller before gameplay.
Wiring It All Together
Console-quality controller support on the web is not one big feature — it is a stack of small, unglamorous details done correctly. Poll every frame, deadzone the sticks, survive hot-plugs, feature-detect rumble, and map vendors through an action layer.
Get those right and a browser game stops feeling like a web page you happen to be playing and starts feeling like software that belongs on the device. For more on the rendering and architecture decisions underneath, see our guides on Canvas vs WebGL rendering and ECS for browser games.


