@wcstack/pointer-lock
v1.19.1
Published
Declarative Pointer Lock component for Web Components. Framework-agnostic Pointer Lock API control via wc-bindable-protocol.
Maintainers
Readme
@wcstack/pointer-lock
@wcstack/pointer-lock is a headless Pointer Lock API component for the wcstack ecosystem.
It is not a visual UI widget.
It is an async primitive node that turns the Pointer Lock API's lock state into reactive state — the same way @wcstack/fullscreen turns the Fullscreen API's active state into reactive state.
With @wcstack/state, <wcs-pointer-lock> can be bound directly through path contracts:
- input surface:
target - output state surface:
active,error - commands:
requestPointerLock,exitPointerLock
@wcstack/pointer-lock follows the CSBC (Core / Shell / Binding Contract) architecture:
- Core (
PointerLockCore) wrapsElement.requestPointerLock()/document.exitPointerLock()/document.pointerLockElement, self-filtering thedocument-scopedpointerlockchangeevent against its own resolved target - Shell (
<wcs-pointer-lock target="...">) resolves which element to operate on from the DOM, manages display and lifecycle - Binding Contract (
static wcBindable) declares the observableactiveproperty and therequestPointerLock/exitPointerLockcommands
A narrow-purpose node — read this before reaching for it
Unlike most wcstack IO nodes, <wcs-pointer-lock> is not aimed at the project's main use case of building declarative SPA UI. The Pointer Lock API's real-world usage is almost exclusively games and canvas/WebGL rendering UI that need the mouse's relative movement (movementX/movementY) — first-person camera controls, drawing-tool panning, and similar. Those consumers typically already run an imperative requestAnimationFrame loop and have less reason to route input through a declarative binding layer than, say, a video player reaching for <wcs-fullscreen>.
Reach for this node when you need a declarative lock on/off switch (e.g. a "Enable mouse look" button wired via the command-token protocol) — not as a source of movementX/movementY data. See below.
movementX/movementY are out of scope (v1)
mousemove events fired while the pointer is locked carry movementX/movementY deltas. This Core does not expose them, in any version up to this one. They are high-frequency data (potentially hundreds of events/sec) that do not fit the same-value-guarded, declarative properties model this protocol is built around — piping them through wc-bindable as-is would risk flooding the bound state with per-frame updates.
If a future version adds them, the design intent (see docs/pointer-lock-tag-design.md §3) is to gate them behind an explicit opt-in and pair them with @wcstack/debounce/@wcstack/throttle for rate-limiting, keeping the "no unbounded firehose" property intact for instances that don't opt in. For now, if you need raw movementX/movementY, read them directly off mousemove in your own imperative code alongside this node's active state.
The target attribute decides what is locked
Like @wcstack/fullscreen, this Shell does not lock itself — it is a hidden control tag that operates on a referenced element via the target attribute, using the same 3-mode resolution as @wcstack/intersection:
| target | operates on | display |
|------------------|--------------------------|-------------|
| omitted | first element child | contents |
| "#selector" | the matched element | none |
| "self" | the element itself | block |
Install
npm install @wcstack/pointer-lockQuick Start
<script type="module" src="https://esm.run/@wcstack/state/auto"></script>
<script type="module" src="https://esm.run/@wcstack/pointer-lock/auto"></script>
<wcs-state>
<script type="module">
export default {
$commandTokens: ["lockPointer", "unlockPointer"],
locked: false,
};
</script>
</wcs-state>
<canvas id="scene" width="640" height="480"></canvas>
<wcs-pointer-lock target="#scene"
data-wcs="active: locked; command.requestPointerLock: $command.lockPointer; command.exitPointerLock: $command.unlockPointer">
</wcs-pointer-lock>
<button data-wcs="onclick: $command.lockPointer">Enable mouse look</button>
<button data-wcs="hidden: locked|not; onclick: $command.unlockPointer">Release</button>The buttons never touch <wcs-pointer-lock> directly: their clicks emit the lockPointer/unlockPointer command tokens, and <wcs-pointer-lock> subscribes to them via command.requestPointerLock: $command.lockPointer / command.exitPointerLock: $command.unlockPointer (the command-token protocol — the element with the command method is the subscriber, not the emitter).
Every bound state path must be declared up front — locked: false here; binding an undeclared path throws at initialization. Negation in a data-wcs path is done with the |not filter (locked|not), not a leading ! — paths do not support prefix operators.
requestPointerLock() requires a user-gesture context — see below.
User gesture requirement
Element.requestPointerLock() rejects with NotAllowedError when called outside a user-gesture context (e.g. a synchronous click handler). This node cannot manufacture a gesture on your behalf: the responsibility for calling requestPointerLock from within an actual user gesture belongs to the caller. Wire the command-token protocol (command.requestPointerLock: $command.<token> on <wcs-pointer-lock>, emitted by a button's onclick: $command.<token>) — calling it from a setTimeout or deep inside a .then() chain loses the gesture context and the call will reject, error will be set, but no exception will propagate (never-throw).
Observable Properties (outputs)
| Property | Event | Description |
| ---------- | ---------------------------- | ------------ |
| active | wcs-pointer-lock:change | true when document.pointerLockElement is this instance's resolved target, false otherwise. |
error (see Commands below) is exposed as a plain getter, not as a wcBindable property — it only changes as a side effect of a command call, mirroring @wcstack/fullscreen. The most recent failure is one of: a rejected promise (e.g. NotAllowedError for a gesture-less call), { message: "Pointer Lock API is not supported." } when the platform API is missing, { message: "Pointer Lock target could not be resolved." } when target did not resolve to an element, or null if the last attempt succeeded / nothing has failed yet.
Commands
| Command | Async | Description |
| --------------------- | ----- | ------------ |
| requestPointerLock | yes | Resolve target and call requestPointerLock() on it. Never-throw: failures (an unresolvable target, NotAllowedError for a missing gesture, or an unsupported API) are captured into error, not thrown. |
| exitPointerLock | no| Calls document.exitPointerLock(). Synchronous — unlike @wcstack/fullscreen's exitFullscreen() (which is Promise-based), exitPointerLock() returns void. Silent no-op if nothing is currently locked or the API is unsupported. |
Attributes / Inputs
| Attribute | Description |
| --------- | ------------ |
| target | Selector (or "self") identifying the element to lock. See "The target attribute decides what is locked" above. Omitted → first element child. |
CSS styling with :state()
<wcs-pointer-lock> reflects its active output onto its
ElementInternals CustomStateSet,
so you can style it directly from CSS with the :state() pseudo-class — no
data-wcs binding or extra class toggling required.
| State | On when |
|-------|---------|
| active | wcs-pointer-lock:change fires with true (cleared on false) |
wcs-pointer-lock:state(active) ~ .crosshair { display: block; }
wcs-pointer-lock:state(active) ~ .crosshair { display: none; } /* default */Unlike attributes or classes, :state() cannot be written from outside the
element, so there is no risk of confusing this output state with an input.
error is intentionally not reflected — see "Observable Properties" above;
it is exposed only as a plain getter, not a wcBindable property.
Browser support (:state(x) syntax): Chrome/Edge 125+, Safari 17.4+,
Firefox 126+. In older browsers the states are simply never set — :state()
selectors never match, but <wcs-pointer-lock> itself keeps working normally
(graceful degradation, never-throw).
SSR: :state() cannot be serialized into HTML, so server-rendered markup
never carries these states on first paint (@wcstack/server is unaffected).
If you need to style the pre-hydration gap, pair your rule with
wcs-pointer-lock:not(:defined) instead.
Debugging
Custom states are invisible in DevTools' Elements panel and attachInternals()
cannot be called twice, so there is no console way to inspect them directly.
Two debug-only aids are provided for that:
el.debugStates— a snapshot array of the currently-on state names (e.g.["active"]). It is not part ofwc-bindable(not a bind target) and its shape is not a guaranteed contract — use it for debugging only.The
debug-statesattribute (opt-in, default off) mirrors state changes onto adata-wcs-state-activeattribute on the element, so the Elements panel highlights it as it toggles:<wcs-pointer-lock target="#scene" debug-states></wcs-pointer-lock>
Write your CSS against :state(), not data-wcs-state-*. The mirrored
attribute exists purely to make state changes visible while debugging with
DevTools open; it is not a supported styling hook.
Multiple instances — read active per-instance, not "is anything locked"
document.pointerLockElement is a single document-wide value — at most one element can be locked at a time. When several <wcs-pointer-lock> instances exist simultaneously (e.g. target="#a" and target="#b"), each instance compares document.pointerLockElement against its own resolved target, not merely "is the document locked". Locking #a makes the target="#a" instance report active: true and the target="#b" instance report active: false — even though some element (#a) is locked document-wide.
Vendor prefixes
Some older WebKit builds expose webkitRequestPointerLock / webkitExitPointerLock / webkitPointerLockElement / the webkitpointerlockchange event instead of the standard names. API resolution happens at call time (never cached), probing the standard name first and falling back to the legacy name — this lets an unsupported environment (neither name present) be detected correctly and lets tests install/remove the API freely.
Notes & limitations
- User gesture required. See above — this is a platform constraint, not something this node can work around.
exitPointerLock()is synchronous, unlike@wcstack/fullscreen's Promise-basedexitFullscreen(). It carries no_gengeneration guard of its own (onlyrequestPointerLock(), being asynchronous, needs one); it is still wrapped intry/catchdefensively so a non-conformant implementation can never throw out of it.movementX/movementYare out of scope for v1. See above.- No autoTrigger. Because
requestPointerLock()needs a user-gesture context, the primary activation path is the command-token protocol (command.requestPointerLock: $command.<token>on<wcs-pointer-lock>) rather than adata-*targetclick shortcut. - SSR (
@wcstack/server). Declaresstatic hasConnectedCallbackPromise = trueand exposesconnectedCallbackPromise; sinceobserve()is synchronous, this promise always settles immediately.
Headless usage (PointerLockCore)
The Core has no DOM dependency (beyond calling the Pointer Lock platform API) and can be used directly with bind() from @wc-bindable/core:
import { PointerLockCore } from "@wcstack/pointer-lock";
const lock = new PointerLockCore();
lock.addEventListener("wcs-pointer-lock:change", (e) => {
console.log((e as CustomEvent).detail); // boolean — the new `active` value directly
});
const canvas = document.querySelector("#scene")!;
lock.observe(canvas); // subscribe to document pointerlockchange, self-filtering on `canvas`
await lock.requestPointerLock(canvas); // must be called from within a user gesture
console.log(lock.active, lock.error);
// later, when done:
lock.exitPointerLock(); // synchronous
lock.dispose(); // detach the document listenerLicense
MIT
