Tutorial · Intermediate · ~50 min
WebXR Device API — First Steps
WebXR is the browser standard for immersive VR and AR. It handles head tracking, stereo rendering, controller poses, and haptics — all from JavaScript. This tutorial walks through entering an inline preview session, upgrading to immersive-vr, handling controller input, and rendering a simple interactive VR scene with Three.js.
1Check XR support and show the button
const btn = document.getElementById('vr-btn'); if (!navigator.xr) {
btn.textContent = 'WebXR not supported'; btn.disabled = true; } else {
// Check if immersive-vr is available (requires HTTPS + headset)
navigator.xr.isSessionSupported('immersive-vr').then(supported => { if
(supported) { btn.textContent = 'Enter VR';
btn.addEventListener('click', startXR); } else { btn.textContent = 'VR
not available on this device'; btn.disabled = true; } }); } // Always
show inline 3D preview (works on any device)
navigator.xr?.isSessionSupported('inline').then(ok => { if (ok)
startInlineSession(); });
WebXR requires HTTPS (or localhost). Use
serve / vite locally — plain
file:// URLs won't work.
2Start an immersive-vr session
import * as THREE from
'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.xr.enabled = true;
document.body.appendChild(renderer.domElement); let xrSession = null;
async function startXR() { xrSession = await
navigator.xr.requestSession('immersive-vr', { requiredFeatures:
['local-floor'], // floor-relative reference space optionalFeatures:
['hand-tracking'], // hand joints if available }); // Tell Three.js
about the session — it handles stereo rendering internally await
renderer.xr.setSession(xrSession); // Session ends when user removes
headset xrSession.addEventListener('end', () => { xrSession = null;
btn.textContent = 'Enter VR'; }); btn.textContent = 'Exit VR'; }
3The XR render loop
In XR mode, Three.js takes over requestAnimationFrame.
Use renderer.setAnimationLoop — it works both in-page and
in VR:
const scene = new THREE.Scene(); const camera = new
THREE.PerspectiveCamera(70, innerWidth/innerHeight, 0.01, 100);
scene.add(camera); // Add some objects to the scene const geo = new
THREE.BoxGeometry(0.2, 0.2, 0.2); const mat = new
THREE.MeshStandardMaterial({ color: 0x22c55e }); for (let i = 0; i
< 10; i++) { const box = new THREE.Mesh(geo, mat);
box.position.set( (Math.random() - 0.5) * 4, Math.random() * 1.5, -1 -
Math.random() * 3 ); scene.add(box); } // Add light scene.add(new
THREE.AmbientLight(0xffffff, 0.5)); const dirLight = new
THREE.DirectionalLight(0xffffff, 1); dirLight.position.set(2, 4, 2);
scene.add(dirLight); // The loop — called by the browser at 90 Hz in
VR renderer.setAnimationLoop((time, frame) => { renderer.render(scene,
camera); });
The
frame argument is an XRFrame — use it to
query controller poses, hand joints, and AR hit tests in the same
loop.
4Read controller poses
// Three.js XRControllerModelFactory handles controller meshes
automatically import { XRControllerModelFactory } from
'https://cdn.jsdelivr.net/npm/three@0.160/examples/jsm/webxr/XRControllerModelFactory.js';
const cmf = new XRControllerModelFactory(); const controllers = [0,
1].map(i => { // Grip space: physical controller model const grip =
renderer.xr.getControllerGrip(i);
grip.add(cmf.createControllerModel(grip)); scene.add(grip); // Ray
space: pointing ray const ctrl = renderer.xr.getController(i);
ctrl.addEventListener('selectstart', onSelectStart);
ctrl.addEventListener('selectend', onSelectEnd); scene.add(ctrl); //
Visualise ray const ray = new THREE.Line( new
THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(0, 0, 0), new
THREE.Vector3(0, 0, -1), ]), new THREE.LineBasicMaterial({ color:
0x22c55e }) ); ctrl.add(ray); return ctrl; }); function
onSelectStart(e) { // Controller pressed trigger const ctrl =
e.target; console.log('Select start from', ctrl === controllers[0] ?
'left' : 'right'); }
5Gamepad buttons and haptics
renderer.setAnimationLoop((time, frame) => { if (frame) { const
session = renderer.xr.getSession(); for (const source of
session.inputSources) { const gp = source.gamepad; if (!gp) continue;
// Standard button layout (OpenXR gamepad profile): // buttons[0] =
trigger, [1] = grip, [3] = thumbstick press, [4/5] = A/B const trigger
= gp.buttons[0]?.value ?? 0; // 0–1 analogue const grip =
gp.buttons[1]?.pressed ?? false; // Analogue thumbstick const stickX =
gp.axes[2] ?? 0; // -1 left, +1 right const stickY = gp.axes[3] ?? 0;
// -1 up, +1 down // Haptic pulse (if available) if (trigger > 0.9
&& source.gamepad.hapticActuators?.length) {
source.gamepad.hapticActuators[0].pulse(trigger * 0.5, 50); } } }
renderer.render(scene, camera); });
Haptics require a brief pulse < 250 ms — longer pulses are silently
ignored by most runtimes. The intensity range is 0–1.