THREE.PointerLockControls: Unable to use Pointer Lock API
Why three.js PointerLockControls prints "THREE.PointerLockControls: Unable to use Pointer Lock API", the pointerlockerror listener behind it, the browser rules that refuse a lock (a missing user gesture above all), and the paprika3d plugin's open, interact and eval replies before and after moving lock() into a click.
PointerLockControls prints this console error when the browser refuses to capture the mouse. The message comes from the addon, but the decision is the browser's: three asked for a lock with requestPointerLock(), and the document answered with a pointerlockerror event. Mouse look stays off, controls.isLocked stays false, and the camera does not turn when the mouse moves.
Where three.js prints it
examples/jsm/controls/PointerLockControls.js (three r186) listens on the document when the controls connect, and the error handler is one line:
connect( element ) {
super.connect( element );
this.domElement.ownerDocument.addEventListener( 'mousemove', this._onMouseMove );
this.domElement.ownerDocument.addEventListener( 'pointerlockchange', this._onPointerlockChange );
this.domElement.ownerDocument.addEventListener( 'pointerlockerror', this._onPointerlockError );
}
// ...
function onPointerlockError() {
console.error( 'THREE.PointerLockControls: Unable to use Pointer Lock API' );
}
lock() calls this.domElement.requestPointerLock( { unadjustedMovement } ) and nothing else, so the addon never knows why the request failed. The message does not mean the browser lacks the API; Chrome, Firefox and desktop Safari all have it. In Chrome, requestPointerLock() also returns a promise, and its rejection carries the reason, which is the line to read next to three's.
Why the browser refuses the lock
- No user gesture. Calling
controls.lock()on page load, or from a timer, a loader callback or the animation loop that no click or key press started, is refused. The lock has to be requested inside a click or another input handler. This is the cause in most threads. - Asking again right after Esc. When the player leaves the lock with Esc, Chrome refuses a new request for a short time, so a game that relocks the moment it sees
unlockgets the error. - An iframe without permission. A game embedded in a sandboxed iframe needs
allow-pointer-lockin itssandboxattribute; editors and site builders often leave it out. - Raw mouse input the platform cannot give.
lock(true)asks forunadjustedMovement, which some systems refuse with aNotSupportedError. - A touch device. iOS and iPadOS browsers expose no pointer lock for touch, so a first-person page needs touch controls there.
A page that reproduces it
The page below builds a floor and a pillar for a first-person walk and calls lock() as soon as the controls exist:
const controls = new PointerLockControls(camera, renderer.domElement);
controls.lock(); // asked for on load, before any click
paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome on a paused clock. The calls in this section ran on 2026-10-28 with the stable build, on a machine with no GPU; the run folder is shortened to <project> and the replies are trimmed to the lines discussed below without rewording.
open {"url": "index.html", "cwd": "<project>"}
Opened http://127.0.0.1:44039/index.html in 0.2 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["floor","pillar"]}
Errors so far:
THREE.PointerLockControls: Unable to use Pointer Lock API
NotAllowedError: A user gesture is required to request Pointer Lock.
eval {"code": "return [document.pointerLockElement === null]"}
[true]
Headless Chrome applies the same activation rule as a desktop window, so the refusal happens during setup and open lists two errors. The first is three's. The second is the rejected promise from requestPointerLock(), and it names the reason: no user gesture. The eval confirms that nothing holds the lock. observe repeats both lines under harness.errors. These replies come from one cause; a refusal inside an iframe or after Esc prints three's line with a different second line, and the reason text is Chrome's, so Firefox words it differently.
The fix
Request the lock from a click on the canvas, and listen for lock and unlock to show or hide a "click to play" overlay. The page below sets the document title instead, which keeps it short:
<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Walk</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js","three/addons/":"./node_modules/three/examples/jsm/"}}</script>
<script type="module">
import * as THREE from 'three';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8fb8de);
scene.add(new THREE.HemisphereLight(0xffffff, 0x445533, 2));
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 200);
camera.position.set(0, 1.6, 5);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(40, 40), new THREE.MeshStandardMaterial({ color: 0x557744 }));
floor.rotation.x = -Math.PI / 2;
floor.name = 'floor';
scene.add(floor);
const pillar = new THREE.Mesh(new THREE.BoxGeometry(1, 3, 1), new THREE.MeshStandardMaterial({ color: 0xcc8844 }));
pillar.position.set(0, 1.5, 0);
pillar.name = 'pillar';
scene.add(pillar);
const controls = new PointerLockControls(camera, renderer.domElement);
renderer.domElement.addEventListener('click', () => controls.lock()); // inside the click's user gesture
controls.addEventListener('lock', () => { document.title = 'Walk (locked)'; });
controls.addEventListener('unlock', () => { document.title = 'Walk'; });
addEventListener('resize', () => {
renderer.setSize(innerWidth, innerHeight);
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
});
renderer.setAnimationLoop(() => renderer.render(scene, camera));
</script>
</body></html>
The page now opens without errors. interact clicks the canvas, which counts as a user gesture, and a relative mouse move then turns the camera:
interact {"steps": [{"mouse": "click", "x": 640, "y": 400, "frames": 2}]}
{"after":null,"changed":{}}
mouse click at (640, 400): over pillar (Mesh, 4.5 units)
eval {"code": "return { locked: document.pointerLockElement === renderer.domElement, title: document.title }"}
{"locked":true,"title":"Walk (locked)"}
interact {"steps": [{"mouse": "move", "relative": [200, 0], "frames": 2}]}
{"after":null,"changed":{}}
eval {"code": "return camera.rotation.y.toFixed(3)"}
-0.400
The lock belongs to the canvas, the lock event ran, and 200 pixels of sideways movement turned the camera by 0.4 radians, which is PointerLockControls' default sensitivity of 0.002 per pixel. changed stays empty because no named object moved; the camera is not one of the page's entities. In a real game, also keep the click handler from relocking on the same frame the unlock event fires, so a player who pressed Esc to reach a menu is not pulled back in.
Where people ask about it
These threads quote the message; each count was read on 2026-10-28.
- Unable to use Pointer Lock API (discourse.threejs.org, 5,164 views, 5 posts)
- THREE.PointerLockControls: Unable to use Pointer Lock API (discourse.threejs.org, 4,378 views, 8 posts)
- How to activate/deactivate PointerLockControls? (discourse.threejs.org, 2,703 views, 4 posts)
- Pointer Lock Controls and iPad (discourse.threejs.org, 1,659 views, 5 posts)
- Pointer lock controls error unlocking (GitHub, 8 comments)