The WebHID gamepad route is what you reach for when the Gamepad API has let you down — when navigator.getGamepads() returns nothing for a device that is plainly plugged in, when mapping is the empty string and the indices are nonsense, or when you want the gyro, the touchpad and the light bar that the Gamepad API does not model at all. It is powerful, it is Chromium-only, and it hands you a DataView and wishes you luck. This is how to use it properly, with a real DualSense report parser at the end.
- Support: Chrome/Edge 89+, Opera 76+, desktop only. No Firefox, no Safari, no mobile. ~30% global.
- Gated: secure context plus transient user activation.
requestDevice()must be inside a click handler. - Raw bytes: no normalisation. You decode the report yourself against a published layout.
- Blocked: mice, keyboards and FIDO usages. Gamepad and joystick usages are allowed.
- Two-way:
sendReport()reaches rumble, LEDs and the light bar. - Status: WICG incubation, one implementation. Progressive enhancement only.
What is WebHID, exactly?
It is raw access to HID input, output and feature reports from a web page, for devices the browser has no purpose-built API for.
The WebHID specification lives at the Web Incubator Community Group. The Chrome capabilities documentation frames the motivation plainly: a long tail of HID devices — exotic gamepads, control surfaces, specialist peripherals — that browsers will never ship bespoke APIs for, and which previously needed a native helper application.
HID itself is a USB-era standard, defined in the USB HID device class specification. A device describes its inputs in a report descriptor, then streams fixed-layout reports. The operating system reads the descriptor and builds a model; the Gamepad API uses that model, plus a per-device remapping database, to produce the clean 17-button standard layout. WebHID skips all of that and gives you the report bytes.
That is the whole trade. The Gamepad API gives you a normalised, portable, incomplete view. WebHID gives you a complete, raw, device-specific view that you must decode yourself.
Where is WebHID supported?
Chromium desktop browsers and nothing else — about 30% of global traffic, per caniuse.
The support table lists Chrome 89+, Edge 89+ and Opera 76+ on desktop, with Firefox and Safari unsupported across every version and no mobile support at all. For comparison, the Gamepad API sits around 97%. That gap is not closing soon; neither Mozilla nor Apple has shipped an implementation, and the spec has not moved into a W3C working group.
Build accordingly. WebHID is a capability you detect and offer as a bonus, never a dependency your app requires to function. The full three-API comparison is in Web MIDI browser support in 2026.
const canHid = 'hid' in navigator && typeof navigator.hid.requestDevice === 'function';
if (!canHid) {
// Fall back to the Gamepad API path - it works nearly everywhere.
useGamepadApi();
} else {
showAdvancedHidButton();
} How do you request a device?
Call navigator.hid.requestDevice() with vendor and product filters, synchronously inside a click handler.
The gesture requirement is absolute and it is the single most common cause of "requestDevice does nothing". Per the user activation model, transient activation is consumed and expires; if you await something before calling requestDevice(), the activation may already be gone. Call it first, await afterwards.
Filters do not restrict what the user can pick — they restrict what the chooser displays. You cannot pre-select a device, you cannot style the chooser, and you cannot avoid it on first use.
// Verified vendor and product ids, taken from the Linux HID driver tables.
export const KNOWN = {
SONY: 0x054c,
NINTENDO: 0x057e
};
export const FILTERS = [
{ vendorId: 0x054c, productId: 0x0ce6 }, // DualSense
{ vendorId: 0x054c, productId: 0x0df2 }, // DualSense Edge
{ vendorId: 0x054c, productId: 0x05c4 }, // DualShock 4 (v1)
{ vendorId: 0x054c, productId: 0x09cc }, // DualShock 4 (v2)
{ vendorId: 0x057e, productId: 0x2006 }, // Joy-Con (L)
{ vendorId: 0x057e, productId: 0x2007 }, // Joy-Con (R)
{ vendorId: 0x057e, productId: 0x2009 } // Switch Pro Controller
];
// MUST be called directly from a click handler.
document.getElementById('pick').addEventListener('click', async () => {
const devices = await navigator.hid.requestDevice({ filters: FILTERS });
if (devices.length === 0) return; // user cancelled the chooser
const device = devices[0];
if (!device.opened) await device.open();
attach(device);
});
Those identifiers are not guesses. They come from the Linux kernel's hid-ids.h, which is the most reliable public table of controller USB ids there is: Sony is 0x054c with the DualSense at 0x0ce6 and the DualSense Edge at 0x0df2; Nintendo is 0x057e with Joy-Con left at 0x2006, right at 0x2007 and the Pro Controller at 0x2009.
How do you keep access across reloads?
Use navigator.hid.getDevices(), which returns devices the user has already granted and needs no gesture.
Permission is per-origin and per-device and it persists. On load, call getDevices() first and only show the chooser button if it comes back empty. navigator.hid also fires connect and disconnect events for already-permitted devices, which is how you survive someone unplugging a pad mid-session.
export async function autoReconnect(attach) {
const granted = await navigator.hid.getDevices();
const device = granted.find(d => FILTERS.some(
f => f.vendorId === d.vendorId && f.productId === d.productId
));
if (device) {
if (!device.opened) await device.open();
attach(device);
return true;
}
return false;
}
navigator.hid.addEventListener('connect', async ({ device }) => {
if (!device.opened) await device.open();
attach(device);
});
navigator.hid.addEventListener('disconnect', ({ device }) => {
detach(device);
}); What does an input report actually look like?
A report id and a DataView of the report body — with the report id byte already removed from the body.
HIDInputReportEvent carries three properties: device, reportId and data. That separation matters enormously when you port offsets from a driver. Kernel code frequently counts the report id as byte 0; WebHID does not. Every "my parser is off by one" bug in WebHID code traces back to this.
function attach(device) {
device.addEventListener('inputreport', (event) => {
const { reportId, data } = event; // data is a DataView, id stripped
if (reportId === 0x01) {
handleDualSenseUsb(data);
} else if (reportId === 0x31) {
// Bluetooth report has a one-byte header before the common body.
handleDualSenseBt(data);
}
});
} A real DualSense report parser
Here is a complete decoder for the DualSense USB input report, with offsets taken from the Linux hid-playstation driver rather than guessed.
The driver defines the USB input report as id 0x01 at 64 bytes total, with a 63-byte body. Its structure, in order, is: left stick X and Y, right stick X and Y, L2 and R2 analogue, a sequence number, four button bytes, four reserved bytes, three 16-bit gyro values, three 16-bit accelerometer values, a 32-bit sensor timestamp, a reserved byte, then two four-byte touch points. The published hardware constants are 1024 gyro units per degree per second, 8192 accelerometer units per g, and a 1920 by 1080 touchpad coordinate space.
// dualsense.js - decodes the DualSense USB input report (reportId 0x01).
// Offsets are relative to the report BODY, i.e. after WebHID strips the id.
// Layout source: Linux drivers/hid/hid-playstation.c
const GYRO_PER_DEG_S = 1024;
const ACCEL_PER_G = 8192;
const TOUCH_W = 1920;
const TOUCH_H = 1080;
export function parseDualSenseUsb(view) {
const u8 = (o) => view.getUint8(o);
const i16 = (o) => view.getInt16(o, true); // little endian
const u32 = (o) => view.getUint32(o, true);
const b0 = u8(7), b1 = u8(8), b2 = u8(9);
const hat = b0 & 0x0f; // 0-7 clockwise from up, 8 = centre
return {
sticks: {
lx: (u8(0) - 128) / 128,
ly: (u8(1) - 128) / 128,
rx: (u8(2) - 128) / 128,
ry: (u8(3) - 128) / 128
},
triggers: { l2: u8(4) / 255, r2: u8(5) / 255 },
seq: u8(6),
buttons: {
square: Boolean(b0 & 0x10),
cross: Boolean(b0 & 0x20),
circle: Boolean(b0 & 0x40),
triangle: Boolean(b0 & 0x80),
l1: Boolean(b1 & 0x01),
r1: Boolean(b1 & 0x02),
l2: Boolean(b1 & 0x04),
r2: Boolean(b1 & 0x08),
create: Boolean(b1 & 0x10),
options: Boolean(b1 & 0x20),
l3: Boolean(b1 & 0x40),
r3: Boolean(b1 & 0x80),
ps: Boolean(b2 & 0x01),
touchpad: Boolean(b2 & 0x02),
micMute: Boolean(b2 & 0x04)
},
dpad: {
up: hat === 0 || hat === 1 || hat === 7,
right: hat === 1 || hat === 2 || hat === 3,
down: hat === 3 || hat === 4 || hat === 5,
left: hat === 5 || hat === 6 || hat === 7
},
gyro: {
x: i16(15) / GYRO_PER_DEG_S,
y: i16(17) / GYRO_PER_DEG_S,
z: i16(19) / GYRO_PER_DEG_S
},
accel: {
x: i16(21) / ACCEL_PER_G,
y: i16(23) / ACCEL_PER_G,
z: i16(25) / ACCEL_PER_G
},
sensorTimestamp: u32(27),
touch: [readTouch(view, 32), readTouch(view, 36)]
};
}
function readTouch(view, o) {
const contact = view.getUint8(o);
const xLo = view.getUint8(o + 1);
const mid = view.getUint8(o + 2);
const yHi = view.getUint8(o + 3);
return {
active: (contact & 0x80) === 0, // bit 7 set means INACTIVE
id: contact & 0x7f,
x: (xLo | ((mid & 0x0f) << 8)) / TOUCH_W, // 0..1
y: (((mid & 0xf0) >> 4) | (yHi << 4)) / TOUCH_H
};
} That single function gives you everything the Gamepad API does plus a six-axis IMU, two independent touch points with pressure-free absolute coordinates, a microphone-mute button, and a sensor timestamp from the device's own clock. Those are the inputs that make a controller interesting as an instrument — a gyro is a continuous two-axis expression surface, and a touchpad is a Kaoss-style XY pad with finger IDs. The touchpad XY guide and Joy-Con gyro motion control cover what to do with them musically.
The Bluetooth report is report id 0x31 at 78 bytes and carries a one-byte header before the same common body, so add one to every offset above. That is also the reason a parser that works over USB silently produces nonsense over Bluetooth.
How do you write back to the device?
device.sendReport(reportId, data) for output reports, and sendFeatureReport / receiveFeatureReport for configuration.
sendReport is how you reach the things no browser API exposes: the DualSense light bar colour, the player indicator LEDs, the haptic motors and the adaptive trigger modes. Feature reports are the configuration channel — on the DualSense, feature report 0x05 is calibration data and 0x09 is pairing information, both documented in the same kernel driver.
The DualSense output report is flag-driven: a pair of "valid flag" bytes tells the controller which of the following fields it should actually apply. Set a value without setting its flag and nothing happens, which is the other classic WebHID dead end.
// Simple rumble on the DualSense over USB (output report id 0x02).
// valid_flag0 bit 0 enables the DualShock4-compatible vibration fields.
export async function rumble(device, left = 0, right = 0) {
const body = new Uint8Array(47);
body[0] = 0x01; // valid_flag0: compatible vibration
body[1] = 0x00; // valid_flag1
body[2] = Math.round(right * 255) & 0xff; // motor_right (high frequency)
body[3] = Math.round(left * 255) & 0xff; // motor_left (low frequency)
await device.sendReport(0x02, body);
}
// Read the factory IMU calibration block.
export async function readCalibration(device) {
const view = await device.receiveFeatureReport(0x05);
return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength));
} Writing output reports is where WebHID starts to feel genuinely dangerous in a good way, and it is also where you should be careful: a malformed output report will at worst put the controller into a confused state until you unplug it. Nothing you can do over HID bricks a DualSense, but test on a pad you can afford to power-cycle.
What is the WebHID blocklist?
A maintained list of devices and usages that WebHID refuses to expose, because raw access to them would be a security disaster.
The list lives in the WebHID repository as a plain text file, and its stated purpose is to block devices, collections and reports that websites must not reach. Two categories dominate. Generic desktop mouse and keyboard usages are blocked because raw HID access to them is a keylogger. FIDO and U2F usages are blocked because authentication hardware has its own purpose-built API and must not be reachable generically. A handful of specific vendor products are blocked individually.
Gamepad and joystick usages are not blocked, which is precisely why the gamepad use case works. But it also means a composite device that exposes a keyboard collection alongside a gamepad collection will have that collection filtered out — some keyboard-and-pad combination devices behave oddly for this reason, and it is not a bug in your code.
Should you use WebHID instead of the Gamepad API?
Only when the Gamepad API genuinely cannot do the job, because WebHID costs you three quarters of your browser reach and all of your portability.
| Need | Gamepad API | WebHID |
|---|---|---|
| Sticks, face buttons, d-pad | Yes, normalised | Yes, you decode it |
| Analogue triggers | Yes, via button.value | Yes, full byte resolution |
| Gyroscope / accelerometer | No | Yes |
| Touchpad coordinates | Effectively no | Yes, two points with ids |
| Battery level | No | Yes, in the status bytes |
| Light bar / player LEDs | No | Yes, via output reports |
| Rumble | Coarse dual-rumble only | Full control |
| Adaptive trigger resistance | No | Yes, with the right output report |
| Unrecognised devices | Raw indices or nothing | Yes, if you write a parser |
| Browser reach | ~97% | ~30% |
| Works without per-device code | Yes | No |
The sane architecture is both. Use the Gamepad API as the universal path, detect navigator.hid, and offer a "connect advanced features" button that upgrades to WebHID for the devices you have written parsers for. That is the same layering described in the browser bridge build guide.
What does this look like in native code?
Almost identical, which is the best argument for prototyping in WebHID first.
The report layouts are the same whether you read them through WebHID in Chrome, through hidapi in C, or through the platform driver APIs. A parser you write and debug in a browser tab ports to a native bridge essentially unchanged — the only thing that changes is how you acquire the device handle. hidapi versus native gamepad drivers covers that comparison, and the macOS GameController deep dive shows the higher-level alternative on Apple platforms.
Prior art worth reading before you write your own: joy-con-webhid is the canonical WebHID controller library and decodes Joy-Con gyro and accelerometer data in the browser, and MidiHID shows the same idea applied natively on macOS.
FAQ
What is WebHID for?
WebHID gives a web page raw access to Human Interface Device reports for devices the browser has no higher-level API for. It is the escape hatch you use when the Gamepad API will not expose a device at all, or exposes only part of it.
Which browsers support WebHID?
Chrome 89 and later, Edge 89 and later, and Opera 76 and later, on desktop only. caniuse puts global support around 30 percent. Firefox and Safari have not implemented it and there is no support on any mobile browser.
When should I use WebHID instead of the Gamepad API?
When the device does not appear in getGamepads at all, when mapping is the empty string and the indices are meaningless, or when you need inputs the Gamepad API does not model such as gyroscope, touchpad coordinates, battery state or LED control.
Can WebHID read my keyboard or mouse?
No. The WebHID blocklist blocks the generic desktop mouse and keyboard usages precisely because raw HID access to them would allow a web page to build a keylogger. FIDO security key usages are blocked too.
Why does requestDevice reject without showing a chooser?
Almost always because it was not called from a user gesture. WebHID requires transient user activation, so the call must happen synchronously inside a click or keypress handler, not after an await or in a timeout.
Does the inputreport event include the report id byte?
No. The event exposes reportId as its own property and the data DataView contains the report body with that byte already removed. Off-by-one errors when porting offsets from a kernel driver usually come from forgetting this.
Can I use WebHID and the Gamepad API on the same controller?
Usually yes, since both are reading rather than exclusively claiming the device, but behaviour varies by platform and by how the OS driver has bound the device. Test it, and build a fallback path rather than assuming.
Is WebHID a W3C standard?
Not yet. The specification is published by the Web Incubator Community Group rather than a W3C working group, and only Chromium has implemented it. Treat it as a progressive enhancement, never as a foundation.
Where to take it next
Pair the parser above with Web MIDI and you have a browser bridge that reaches the gyro and touchpad, which is more than almost any browser controller tool does. Test the MIDI side of it against the visualiser while you work — seeing the CC values move is a faster debugging loop than reading hex.
The ceiling is still a tab: 30% browser reach, no background operation, and a chooser dialog on every new device. When you want the gyro, the touchpad and the triggers available to every application on macOS, Windows or Linux with no browser involved, Universal Controller MIDI does the same decoding in a native process. Free tier: one controller, one virtual port, three saved presets. Pro: AUD $89 one-time.