VR controllers

A controller reaches your code as an XRInputSource: two separate spaces, an optional gamepad, and a select event that works the same on every device — including the ones with no controller at all.

Pointer ray and select, live

A controller sweeping a row of targets. The ray originates from the target ray space, not from the hand — turn on the grip marker to see how far apart those two actually are, and watch a hit fire the select event and a haptic pulse.

This demo needs WebGL, which your browser did not provide. The explanation below covers the same material on its own.

Loading demo…

What arrives when a controller connects

A session exposes its inputs as session.inputSources, a live list of XRInputSource objects. Each one describes a thing the user can point or act with: a tracked controller, a hand, a phone screen tap, or the headset gaze itself on devices with no controller at all. Treating that list as "the two controllers" is the first mistake — a session can legitimately have zero, one, or three.

The list is live in the sense that it changes: controllers wake up, go to sleep, get picked up mid-session. If you read inputSources once at session start you will miss every controller that connects a second later, which on standalone headsets is most of them. Listen for inputsourceschange instead and treat connection as an event, not a startup condition.

Every input source carries a handedness of left, right or none, and a profiles array naming the hardware from most to least specific. profiles is what you match against to pick a controller model — never the product name, and never an index into some list you built by hand.

Two spaces, and they are not the same place

This is the single most common source of controllers that look correct but point slightly wrong. An input source exposes two poses, and they exist because they answer two different questions.

targetRaySpace
Where the pointing ray starts and which way it aims. On a tracked controller this sits ahead of the device and is tilted to match how people naturally aim — it is deliberately not the physical device origin. Use it for every pointing, picking and UI interaction.
gripSpace
Where the user's hand is, oriented so that a held object renders correctly. Use it to attach a controller model, a sword, a flashlight — anything the hand is holding. Using it for pointing produces a ray that aims through the wrist.
targetRayMode
How the ray is being produced: tracked-pointer for a real controller, gaze for headsets without one (the ray is the head direction), screen for a phone tap in AR, and transient-pointer for a pinch that only exists while the gesture lasts. Your interaction code should branch on this, not on whether a gamepad exists.

Select is the portable action

Every input source fires select, selectstart and selectend for its primary action. A trigger pull, a pinch, a screen tap, a gaze dwell — all of them arrive as the same three events. Polling gamepad.buttons[0] instead works on the headset you own and silently does nothing on the ones you do not.

// three.js wraps input sources as Object3Ds already positioned in world space.
// getController(i) tracks the TARGET RAY space; getControllerGrip(i) tracks the hand.
const controller = renderer.xr.getController(0);
const grip = renderer.xr.getControllerGrip(0);
scene.add(controller, grip);

// The ray is a child of the controller, so it inherits the pose for free.
controller.add(new THREE.Line(rayGeometry, rayMaterial));

controller.addEventListener('selectstart', (event) => {
  // event.data is the XRInputSource behind this controller.
  const source = event.data;

  // handedness is 'left' | 'right' | 'none' -- 'none' is normal for gaze
  // and screen input, not an error to guard against.
  if (source.handedness === 'right') { /* ... */ }

  const hit = raycastFromController(controller);
  if (hit) pulse(source, 0.6, 40);
});

// Controllers connect and disconnect mid-session. Reading session.inputSources
// once at startup misses nearly every standalone headset's controllers.
session.addEventListener('inputsourceschange', (event) => {
  for (const source of event.added) attachModel(source);
  for (const source of event.removed) detachModel(source);
});

controller.addEventListener works because three.js forwards the session events onto the controller object. Under the hood these are still the session's select events — nothing is being polled.

Haptics, defensively

Haptic actuators are optional at every level: the gamepad may be absent, the hapticActuators array may be empty, and pulse() may reject. None of that is exceptional, so none of it deserves a thrown error.

function pulse(source, intensity = 0.5, durationMs = 30) {
  // Optional chaining the whole way down: a gaze input has no gamepad,
  // a hand has no actuators, and some runtimes expose an empty array.
  const actuator = source.gamepad?.hapticActuators?.[0];
  if (!actuator) return;

  // Fire and forget. Awaiting this inside a frame callback stalls the loop,
  // and a rejected pulse is not worth breaking an interaction over.
  actuator.pulse(intensity, durationMs)?.catch(() => {});
}

Intensity is 0–1 and duration is milliseconds. Keep pulses under about 50 ms for discrete feedback like a button press — anything longer reads as a malfunction rather than a confirmation.

What the demo above is doing

A single controller sweeps left to right across three targets. The ray you see starts at the target ray space, and turning on the grip marker drops a second marker where the hand would be — the gap between them is exactly the offset that makes grip-based pointing aim low and to the side.

When the ray crosses a target, the demo does what a real interaction would: it fires the equivalent of a select, highlights the hit, and triggers a short pulse. On desktop the pulse is drawn as a brief flash on the controller since there is nothing to vibrate; in a real session the same code path calls hapticActuators[0].pulse().

What each platform gives you

The important column is targetRayMode. Code written against it works on all of these; code written against "does a gamepad exist" works on the first row only.

PlatformtargetRayModeHapticsNotes
Meta Quest controllerstracked-pointerYesFull gamepad with thumbstick, trigger, squeeze; profiles names the exact model.
Quest hand trackingtracked-pointerNoA pinch produces select with no gamepad attached — the portable path is the only path.
Android AR (phone)screenNoA tap becomes a transient input source that exists only for the duration of the touch.
visionOStransient-pointerNoGaze plus pinch. The input source appears at pinch time and disappears after.
Desktop + OpenXR runtimetracked-pointerUsuallyDepends on the runtime and the physical controller; treat actuators as optional.

Checked 2026-09. Verify against the WebXR input profiles registry and MDN before relying on any row.

Mistakes that cost the most time

Each of these produces code that runs fine on the hardware sitting on your desk and is wrong everywhere else, which is why they survive so long.

Pointing from gripSpace
The ray leaves the wrist instead of the fingertip and aims a few degrees low. It looks almost right, so it usually survives until someone tries to hit a small target across the room.
Hardcoding gamepad button indices
Button layouts differ between controllers. Match on profiles and the standard mapping, or stick to select and squeeze, which are defined for everything.
Reading inputSources only at startup
On standalone headsets the controllers frequently connect after the session begins. Handle inputsourceschange or your app starts with no input at all.
Assuming handedness is left or right
'none' is a normal value for gaze, screen and some transient inputs. Code that switches on left/right and falls through silently drops those devices entirely.
Awaiting pulse() in the frame loop
Haptics return a promise. Awaiting it inside the animation callback stalls rendering for the duration of the pulse. Fire and forget.

Further reading

The input profiles registry is the part people usually do not know exists, and it is what makes controller models work across hardware you have never tested on.