JavaScript Pointer Lock API Table
| Piece | What it does | Field note |
|---|---|---|
requestPointerLock() | The lock ask | gesture-gated; modern engines return a Promise, older return void + events - handle both shapes |
movementX / movementY | The raw deltas | unbounded per-event motion, no screen edge - the only input an FPS camera reads |
pointerlockchange | The state event | document.pointerLockElement is the single truth; null = exited (Esc or you) - trigger pause, not panic |
pointerlockerror | The failure event | missing gesture, iframe without allow, or the re-lock throttle - log it, the user just sees a stuck game |
exitPointerLock() | The release | call it when DOM UI opens - hidden cursor over a text field is the classic bug; Esc always works too |
unadjustedMovement | The raw input option | skips OS acceleration for true 1:1 aim - promise rejects where unsupported, fall back to plain call |
the re-lock throttle | The cooldown | Chrome rejects re-request ~1.25s after an Esc exit - debounce the resume button, it needs a real click anyway |
iframe policy | The embed gate | cross-origin frames need allow="pointer-lock" or the lock silently never engages |
Pointer Lock hides the cursor and hands a page raw mouse motion: element.requestPointerLock() makes that element receive movementX/movementY deltas that never hit a screen edge and are unaffected by window bounds - the input model first-person games, 3D editors and canvas brushes need. The DOM cursor position freezes; the deltas keep flowing; the OS pointer (and your muscle memory of where it was) stops mattering until the lock ends.
Bottom line: the API is a contract with browser UX, not just a function call. The lock must be requested inside a user gesture - a click-to-play overlay is the API's shape, not polish you can skip. The user can always leave with Esc (the browser shows its own exit hint overlay); treat a pointerlockchange to null as a pause trigger, not an error to fight. And exiting then immediately re-requesting fails: Chrome throttles re-locks for roughly a second after an Esc exit, so your re-lock button needs debouncing and a real click.
The two pro details most pages miss: unadjustedMovement: true requests the mouse's raw motion with OS acceleration curves removed (the difference between consistent flick aims and inconsistent ones - rejected by platforms that cannot honor it, so probe via the promise), and cross-origin embeds need allow="pointer-lock" or the lock silently never engages. Feature-detect nothing here except that promise: pointerlockerror and pointerlockchange are how the API talks.
How to use
- Lock on click: canvas.addEventListener('click', () => canvas.requestPointerLock()) - gesture-gated; modern engines return a Promise (catch rejection for unsupported options), older ones return void and report via events. Handle both shapes.
- Track deltas: canvas.addEventListener('mousemove', e => { yaw += e.movementX * sens; pitch -= e.movementY * sens; }) - movementX/Y are deltas in CSS pixels, unbounded, no clamping needed; the cursor never leaves, so there is no edge to hit.
- Watch state, not wishes: document.addEventListener('pointerlockchange', () => { const locked = document.pointerLockElement === canvas; ... }) - pointerLockElement is the single source of truth; null means the user (or you) exited: show the pause overlay and stop game time.
- Handle failure: listen for pointerlockerror - the usual causes are a missing user gesture, a sandboxed/cross-origin iframe without allow="pointer-lock", or the re-lock throttle firing. Log it; the user only sees a game that would not start.
- Request raw input: canvas.requestPointerLock({unadjustedMovement: true}) skips OS mouse acceleration for true 1:1 motion - the pro-aim setting; the promise rejects where the OS cannot do it, so fall back to the plain call. Release with document.exitPointerLock() when your UI takes over.
Frequently asked questions
Why does requestPointerLock fail or throw when I call it?
Four usual suspects, in order of frequency. Gesture: it must run inside a real user interaction - calling it from an onload handler, a setTimeout, or a synthetic event fails (older engines fire pointerlockerror, newer ones reject the promise); this is why every pointer-lock page ships a click overlay. Throttle: after the user exits with Esc, browsers (Chrome for about 1.25 seconds) reject an immediate re-request - debounce your resume button. Permissions policy: inside a cross-origin iframe the parent must allow="pointer-lock" - the lock then just never engages, and teams debug everything except the iframe attribute. Element state: the element must be in the document and rendered. The debugging order: gesture, throttle, iframe policy, then element.
How do movementX and movementY actually behave?
They are the raw per-event mouse deltas while locked: how far the pointer moved since the last mousemove event, unaffected by screen edges, DPI zoom, or the window being maximized - which is exactly why games use them (a mouse can keep turning a camera forever). Three gotchas. First, they arrive in CSS pixels of the LOCKED element's context, and on some platforms with some mice the deltas are already sampled at the device rate, so sensitivity constants are per-app tuning, not physics. Second, without unadjustedMovement the OS acceleration curve is baked in - slow hand = small delta, fast flick = huge delta, nonlinearly - which is why pro settings pages offer raw input toggles. Third, when NOT locked, movementX/Y still exist on regular mousemove events (they equal ordinary movement deltas) - so code that reads them without checking pointerLockElement misbehaves the moment the user presses Esc.
What is the right UX when the user presses Esc?
Treat it as a designed state, not an interruption. The browser deliberately shows its own overlay (press Esc to exit - and on some engines a hold-to-exit hint for kiosk-style pages) because hiding the cursor is disorienting; never attempt to defeat it (rapid re-request loops get throttled anyway and feel hostile). The pattern: on pointerlockchange to null, pause simulation time, show a menu or resume overlay, and re-lock only from a genuine click on the resume button - which also naturally satisfies the gesture requirement and sidesteps the throttle. Also exitPointerLock() yourself whenever a text input, modal or settings panel opens; mixing a hidden cursor with DOM UI is the classic pointer-lock usability bug.
Do I still need pointer lock for camera controls, or will Pointer Events do?
Different tools for different feels. Pointer Events (pointermove) give you deltas too, and drag-to-look works well for orbit cameras, panoramas and 3D product viewers - because there the cursor stays visible and the user expects bounded motion. Pointer Lock exists for unbounded, cursor-free control: FPS and flight sims, CAD-style camera flights, drawing surfaces that must not lose stroke continuity at screen edges. The cost side decides: pointer lock costs you the cursor, an Esc-handling UX, a gesture gate and iframe policy - if your interaction survives with a visible cursor, plain pointer events are the simpler, friendlier build. Reach for the lock when hitting the screen edge would break the metaphor (turning, aiming, painting past the frame).